1use std::time::SystemTime;
15
16use crate::config::{ConfigResult, SessionConfig};
17use crate::errors::Result;
18use crate::lease::{LeaseAcquisition, LeaseId, RenewalLease};
19use crate::logout::{LogoutOutcome, LogoutRequest, LogoutScope};
20use crate::renewal::{
21 AuthTokenState, RenewalDecision, RenewalOrchestrator, RenewalOutcome, RenewalRequest,
22 RenewalRequirement,
23};
24use crate::repository::{
25 CreateSession, RevokeSessionScope, RotateRefreshToken, RotateRefreshTokenOutcome,
26 SessionRepository,
27};
28use crate::session::{Session, SessionFamilyId};
29use crate::tokens::{
30 AuthTokenIssuer, IssuedSessionTokens, RefreshTokenGenerator, RefreshTokenHasher,
31 RefreshTokenPlaintext, TokenPairIssuer,
32};
33
34#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct IssuedSession {
70 pub session: Session,
72 pub tokens: IssuedSessionTokens,
74}
75
76impl IssuedSession {
77 #[must_use]
79 pub fn new(session: Session, tokens: IssuedSessionTokens) -> Self {
80 Self { session, tokens }
81 }
82}
83
84#[derive(Debug, Clone)]
145pub struct SessionIssuer<R, A, G, H> {
146 config: SessionConfig,
147 repository: R,
148 token_pair_issuer: TokenPairIssuer<A, G, H>,
149}
150
151impl<R, A, G, H> SessionIssuer<R, A, G, H> {
152 pub fn new(
159 config: SessionConfig,
160 repository: R,
161 token_pair_issuer: TokenPairIssuer<A, G, H>,
162 ) -> ConfigResult<Self> {
163 Ok(Self {
164 config: config.validate()?,
165 repository,
166 token_pair_issuer,
167 })
168 }
169}
170
171impl<R, A, G, H> SessionIssuer<R, A, G, H>
172where
173 R: SessionRepository,
174 A: AuthTokenIssuer<Session>,
175 G: RefreshTokenGenerator,
176 H: RefreshTokenHasher,
177{
178 pub async fn issue_session(
185 &self,
186 subject_id: impl Into<String>,
187 now: SystemTime,
188 ) -> Result<IssuedSession> {
189 let session = Session::new(
190 SessionFamilyId::new(),
191 subject_id,
192 now,
193 now + self.config.refresh_token_ttl,
194 );
195 let tokens = self.token_pair_issuer.issue_for_subject(&session).await?;
196
197 self.repository
198 .create_session(CreateSession {
199 session: session.clone(),
200 refresh_token_hash: tokens.refresh_token_hash.clone(),
201 })
202 .await?;
203
204 Ok(IssuedSession::new(session, tokens))
205 }
206}
207
208#[derive(Debug, Clone)]
270pub struct SessionRenewer<R, A, G, H> {
271 config: SessionConfig,
272 repository: R,
273 token_pair_issuer: TokenPairIssuer<A, G, H>,
274 orchestrator: RenewalOrchestrator,
275}
276
277impl<R, A, G, H> SessionRenewer<R, A, G, H> {
278 pub fn new(
284 config: SessionConfig,
285 repository: R,
286 token_pair_issuer: TokenPairIssuer<A, G, H>,
287 ) -> ConfigResult<Self> {
288 Ok(Self {
289 config: config.validate()?,
290 repository,
291 token_pair_issuer,
292 orchestrator: RenewalOrchestrator::new(),
293 })
294 }
295}
296
297impl<R, A, G, H> SessionRenewer<R, A, G, H>
298where
299 R: SessionRepository,
300 A: AuthTokenIssuer<Session>,
301 G: RefreshTokenGenerator,
302 H: RefreshTokenHasher,
303{
304 pub async fn renew_session(
314 &self,
315 auth_token_state: AuthTokenState,
316 requirement: RenewalRequirement,
317 refresh_token: &RefreshTokenPlaintext,
318 now: SystemTime,
319 ) -> Result<RenewalOutcome> {
320 let preliminary_decision = self
321 .orchestrator
322 .decide_for_state(auth_token_state, requirement);
323
324 match preliminary_decision {
325 RenewalDecision::NoRenewal => return Ok(RenewalOutcome::NotNeeded),
326 RenewalDecision::Reject => return Ok(RenewalOutcome::Rejected),
327 RenewalDecision::AttemptProactiveRenewal | RenewalDecision::RequireRenewal => {}
328 }
329
330 let presented_refresh_token_hash = self
331 .token_pair_issuer
332 .hash_refresh_token(refresh_token)
333 .await?;
334 let lookup = self
335 .repository
336 .find_session_by_refresh_token_hash(presented_refresh_token_hash.as_str())
337 .await?;
338
339 let Some(lookup) = lookup else {
340 return Ok(RenewalOutcome::Rejected);
341 };
342
343 if !lookup.is_active_at(now) {
344 return Ok(RenewalOutcome::Rejected);
345 }
346
347 let request = RenewalRequest::new(
348 lookup.session.session_id,
349 auth_token_state,
350 requirement,
351 now,
352 );
353
354 match self.orchestrator.decide(&request) {
355 RenewalDecision::NoRenewal => return Ok(RenewalOutcome::NotNeeded),
356 RenewalDecision::Reject => return Ok(RenewalOutcome::Rejected),
357 RenewalDecision::AttemptProactiveRenewal | RenewalDecision::RequireRenewal => {}
358 }
359
360 let proposed_lease = RenewalLease::from_ttl(
361 lookup.session.session_id,
362 LeaseId::new(),
363 now,
364 self.config.renewal_lease_ttl,
365 );
366
367 let acquisition = self
368 .repository
369 .try_acquire_renewal_lease(lookup.session.session_id, proposed_lease)
370 .await?;
371
372 let acquired_lease = match acquisition {
373 LeaseAcquisition::Acquired(lease) => lease,
374 LeaseAcquisition::HeldByOther { .. } | LeaseAcquisition::Unavailable => {
375 return Ok(RenewalOutcome::LeaseUnavailable { acquisition });
376 }
377 };
378
379 let mut next_session = lookup.session.clone().touched(now);
380 next_session.expires_at = now + self.config.refresh_token_ttl;
381
382 let issued = self
383 .token_pair_issuer
384 .issue_for_subject(&next_session)
385 .await?;
386 let rotate_outcome = self
387 .repository
388 .rotate_refresh_token(RotateRefreshToken {
389 session_id: next_session.session_id,
390 family: lookup.family.clone(),
391 lease: acquired_lease,
392 previous_refresh_token_hash: presented_refresh_token_hash,
393 next_refresh_token_hash: issued.refresh_token_hash.clone(),
394 next_session: next_session.clone(),
395 })
396 .await?;
397
398 match rotate_outcome {
399 RotateRefreshTokenOutcome::Rotated => Ok(RenewalOutcome::Renewed {
400 session: next_session,
401 tokens: issued.token_pair,
402 }),
403 RotateRefreshTokenOutcome::SessionMissing => Ok(RenewalOutcome::Rejected),
404 RotateRefreshTokenOutcome::LeaseUnavailable => Ok(RenewalOutcome::LeaseUnavailable {
405 acquisition: LeaseAcquisition::Unavailable,
406 }),
407 RotateRefreshTokenOutcome::RefreshTokenMismatch => {
408 self.repository
409 .revoke_family(lookup.family.family_id)
410 .await?;
411 Ok(RenewalOutcome::ReplayDetected {
412 session_id: lookup.session.session_id,
413 })
414 }
415 }
416 }
417}
418
419#[derive(Debug, Clone)]
447pub struct SessionRevoker<R> {
448 repository: R,
449}
450
451impl<R> SessionRevoker<R> {
452 #[must_use]
454 pub fn new(repository: R) -> Self {
455 Self { repository }
456 }
457}
458
459impl<R> SessionRevoker<R>
460where
461 R: SessionRepository,
462{
463 pub async fn revoke_session(&self, request: LogoutRequest) -> Result<LogoutOutcome> {
469 match request.scope {
470 LogoutScope::CurrentSession => {
471 self.repository
472 .revoke_session(request.session_id, RevokeSessionScope::CurrentSession)
473 .await?;
474 Ok(LogoutOutcome::CurrentSessionRevoked)
475 }
476 LogoutScope::SessionFamily => {
477 self.repository.revoke_family(request.family_id).await?;
478 Ok(LogoutOutcome::SessionFamilyRevoked)
479 }
480 }
481 }
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487 use crate::lease::{LeaseAcquisition, LeaseId, LeaseTtl, RenewalLease};
488 use crate::repository::{CreateSession, RotateRefreshToken};
489 use crate::session::{
490 SessionFamilyRecord, SessionId, SessionLookup, SessionRefreshRecord, SessionTouch,
491 };
492 use crate::tokens::{
493 AuthToken, OpaqueRefreshTokenGenerator, RefreshTokenLength, Sha256RefreshTokenHasher,
494 };
495 use std::sync::Arc;
496 use std::time::Duration;
497 use tokio::sync::Mutex;
498
499 #[derive(Debug, Clone, Copy)]
500 struct StaticSessionAuthTokenIssuer;
501
502 impl AuthTokenIssuer<Session> for StaticSessionAuthTokenIssuer {
503 type Error = crate::errors::TokenError;
504
505 fn issue_auth_token(
506 &self,
507 session: &Session,
508 ) -> impl std::future::Future<Output = std::result::Result<AuthToken, Self::Error>> + Send
509 {
510 std::future::ready(AuthToken::new(format!("auth-{}", session.subject_id)))
511 }
512 }
513
514 #[derive(Debug, Clone)]
515 struct MockRepository {
516 state: Arc<Mutex<MockRepositoryState>>,
517 }
518
519 #[derive(Debug, Clone)]
520 struct MockRepositoryState {
521 created_sessions: Vec<CreateSession>,
522 lookup_result: Option<SessionLookup>,
523 lookup_calls: usize,
524 lease_acquisition: Option<LeaseAcquisition>,
525 rotate_outcome: RotateRefreshTokenOutcome,
526 rotate_inputs: Vec<RotateRefreshToken>,
527 revoked_sessions: Vec<(SessionId, RevokeSessionScope)>,
528 revoked_families: Vec<SessionFamilyId>,
529 }
530
531 impl Default for MockRepositoryState {
532 fn default() -> Self {
533 Self {
534 created_sessions: Vec::new(),
535 lookup_result: None,
536 lookup_calls: 0,
537 lease_acquisition: None,
538 rotate_outcome: RotateRefreshTokenOutcome::Rotated,
539 rotate_inputs: Vec::new(),
540 revoked_sessions: Vec::new(),
541 revoked_families: Vec::new(),
542 }
543 }
544 }
545
546 impl MockRepository {
547 fn new() -> Self {
548 Self {
549 state: Arc::new(Mutex::new(MockRepositoryState::default())),
550 }
551 }
552 }
553
554 impl SessionRepository for MockRepository {
555 fn create_session(
556 &self,
557 input: CreateSession,
558 ) -> impl std::future::Future<Output = crate::repository::RepositoryResult<()>> + Send
559 {
560 let state = Arc::clone(&self.state);
561
562 async move {
563 state.lock().await.created_sessions.push(input);
564 Ok(())
565 }
566 }
567
568 fn find_session_by_refresh_token_hash<'a>(
569 &'a self,
570 _refresh_token_hash: crate::tokens::RefreshTokenHashRef<'a>,
571 ) -> impl std::future::Future<
572 Output = crate::repository::RepositoryResult<Option<SessionLookup>>,
573 > + Send
574 + 'a {
575 let state = Arc::clone(&self.state);
576
577 async move {
578 let mut guard = state.lock().await;
579 guard.lookup_calls += 1;
580 Ok(guard.lookup_result.clone())
581 }
582 }
583
584 async fn find_session(
585 &self,
586 _session_id: SessionId,
587 ) -> crate::repository::RepositoryResult<Option<crate::session::SessionRecord>> {
588 Ok(None)
589 }
590
591 async fn find_family(
592 &self,
593 _family_id: SessionFamilyId,
594 ) -> crate::repository::RepositoryResult<Option<SessionFamilyRecord>> {
595 Ok(None)
596 }
597
598 async fn find_refresh_record(
599 &self,
600 _session_id: SessionId,
601 ) -> crate::repository::RepositoryResult<Option<SessionRefreshRecord>> {
602 Ok(None)
603 }
604
605 async fn try_acquire_renewal_lease(
606 &self,
607 _session_id: SessionId,
608 lease: RenewalLease,
609 ) -> crate::repository::RepositoryResult<LeaseAcquisition> {
610 let configured = self.state.lock().await.lease_acquisition;
611 Ok(configured.unwrap_or(LeaseAcquisition::Acquired(lease)))
612 }
613
614 async fn rotate_refresh_token(
615 &self,
616 input: RotateRefreshToken,
617 ) -> crate::repository::RepositoryResult<RotateRefreshTokenOutcome> {
618 let mut guard = self.state.lock().await;
619 guard.rotate_inputs.push(input);
620 Ok(guard.rotate_outcome)
621 }
622
623 async fn revoke_session(
624 &self,
625 session_id: SessionId,
626 scope: RevokeSessionScope,
627 ) -> crate::repository::RepositoryResult<()> {
628 self.state
629 .lock()
630 .await
631 .revoked_sessions
632 .push((session_id, scope));
633 Ok(())
634 }
635
636 async fn revoke_family(
637 &self,
638 family_id: SessionFamilyId,
639 ) -> crate::repository::RepositoryResult<()> {
640 self.state.lock().await.revoked_families.push(family_id);
641 Ok(())
642 }
643
644 async fn touch_session(
645 &self,
646 _touch: SessionTouch,
647 ) -> crate::repository::RepositoryResult<()> {
648 Ok(())
649 }
650 }
651
652 fn sample_time() -> SystemTime {
653 SystemTime::UNIX_EPOCH + Duration::from_secs(10_000)
654 }
655
656 fn valid_config() -> SessionConfig {
657 SessionConfig::default()
658 }
659
660 fn valid_refresh_token(value: &str) -> RefreshTokenPlaintext {
661 match RefreshTokenPlaintext::new(value) {
662 Ok(token) => token,
663 Err(error) => panic!("expected valid refresh token: {error}"),
664 }
665 }
666
667 fn token_pair_issuer() -> TokenPairIssuer<
668 StaticSessionAuthTokenIssuer,
669 OpaqueRefreshTokenGenerator,
670 Sha256RefreshTokenHasher,
671 > {
672 let generator = match RefreshTokenLength::new(48) {
673 Ok(length) => OpaqueRefreshTokenGenerator::new(length),
674 Err(error) => panic!("expected valid refresh-token length: {error}"),
675 };
676
677 TokenPairIssuer::new(
678 StaticSessionAuthTokenIssuer,
679 generator,
680 Sha256RefreshTokenHasher,
681 )
682 }
683
684 fn sample_lookup(now: SystemTime) -> SessionLookup {
685 let session = Session::new(
686 SessionFamilyId::new(),
687 "subject-123",
688 now - Duration::from_secs(30),
689 now + Duration::from_secs(3_600),
690 );
691 let family = SessionFamilyRecord::new(
692 session.family_id,
693 session.subject_id.clone(),
694 now - Duration::from_secs(30),
695 );
696 let refresh = SessionRefreshRecord::new(
697 session.session_id,
698 session.family_id,
699 now + Duration::from_secs(3_600),
700 );
701
702 SessionLookup::new(session, family, refresh)
703 }
704
705 fn sample_lease(session_id: SessionId, now: SystemTime) -> RenewalLease {
706 RenewalLease::from_ttl(
707 session_id,
708 LeaseId::new(),
709 now,
710 LeaseTtl::new(Duration::from_secs(30)),
711 )
712 }
713
714 #[tokio::test]
715 async fn session_issuer_persists_session_and_returns_tokens() {
716 let repository = MockRepository::new();
717 let issuer =
718 match SessionIssuer::new(valid_config(), repository.clone(), token_pair_issuer()) {
719 Ok(issuer) => issuer,
720 Err(error) => panic!("expected valid session issuer configuration: {error}"),
721 };
722 let now = sample_time();
723
724 let issued = match issuer.issue_session("subject-123", now).await {
725 Ok(issued) => issued,
726 Err(error) => panic!("expected successful session issuance: {error}"),
727 };
728
729 let guard = repository.state.lock().await;
730
731 assert_eq!(issued.session.subject_id, "subject-123");
732 assert_eq!(guard.created_sessions.len(), 1);
733 assert_eq!(guard.created_sessions[0].session, issued.session);
734 assert_eq!(
735 guard.created_sessions[0].refresh_token_hash,
736 issued.tokens.refresh_token_hash
737 );
738 }
739
740 #[tokio::test]
741 async fn renewer_returns_not_needed_without_repository_lookup_for_valid_tokens() {
742 let repository = MockRepository::new();
743 let renewer =
744 match SessionRenewer::new(valid_config(), repository.clone(), token_pair_issuer()) {
745 Ok(renewer) => renewer,
746 Err(error) => panic!("expected valid renewer configuration: {error}"),
747 };
748 let now = sample_time();
749 let refresh_token = valid_refresh_token("refresh-token-not-used");
750
751 let outcome = match renewer
752 .renew_session(
753 AuthTokenState::Valid {
754 expires_in: Duration::from_secs(600),
755 },
756 RenewalRequirement::Proactive,
757 &refresh_token,
758 now,
759 )
760 .await
761 {
762 Ok(outcome) => outcome,
763 Err(error) => panic!("expected successful renewal evaluation: {error}"),
764 };
765
766 assert_eq!(outcome, RenewalOutcome::NotNeeded);
767 assert_eq!(repository.state.lock().await.lookup_calls, 0);
768 }
769
770 #[tokio::test]
771 async fn renewer_rotates_and_returns_tokens_when_renewal_succeeds() {
772 let repository = MockRepository::new();
773 let now = sample_time();
774 {
775 let mut guard = repository.state.lock().await;
776 let lookup = sample_lookup(now);
777 guard.lookup_result = Some(lookup.clone());
778 guard.lease_acquisition = Some(LeaseAcquisition::Acquired(sample_lease(
779 lookup.session.session_id,
780 now,
781 )));
782 guard.rotate_outcome = RotateRefreshTokenOutcome::Rotated;
783 }
784
785 let renewer =
786 match SessionRenewer::new(valid_config(), repository.clone(), token_pair_issuer()) {
787 Ok(renewer) => renewer,
788 Err(error) => panic!("expected valid renewer configuration: {error}"),
789 };
790 let refresh_token = valid_refresh_token("lookup-refresh-token");
791
792 let outcome = match renewer
793 .renew_session(
794 AuthTokenState::NearExpiry {
795 expires_in: Duration::from_secs(30),
796 },
797 RenewalRequirement::Proactive,
798 &refresh_token,
799 now,
800 )
801 .await
802 {
803 Ok(outcome) => outcome,
804 Err(error) => panic!("expected successful renewal: {error}"),
805 };
806
807 let guard = repository.state.lock().await;
808
809 match outcome {
810 RenewalOutcome::Renewed { session, tokens } => {
811 assert_eq!(session.last_seen_at, Some(now));
812 assert_eq!(session.expires_at, now + valid_config().refresh_token_ttl);
813 assert_eq!(tokens.auth_token.as_str(), "auth-subject-123");
814 assert_eq!(guard.rotate_inputs.len(), 1);
815 assert_eq!(guard.rotate_inputs[0].next_session, session);
816 }
817 other => panic!("expected renewed outcome, got {other:?}"),
818 }
819 }
820
821 #[tokio::test]
822 async fn renewer_reports_lease_unavailable_when_another_actor_holds_it() {
823 let repository = MockRepository::new();
824 let now = sample_time();
825 let lookup = sample_lookup(now);
826 let active_lease = sample_lease(lookup.session.session_id, now);
827 {
828 let mut guard = repository.state.lock().await;
829 guard.lookup_result = Some(lookup.clone());
830 guard.lease_acquisition = Some(LeaseAcquisition::HeldByOther { active_lease });
831 }
832
833 let renewer =
834 match SessionRenewer::new(valid_config(), repository.clone(), token_pair_issuer()) {
835 Ok(renewer) => renewer,
836 Err(error) => panic!("expected valid renewer configuration: {error}"),
837 };
838 let refresh_token = valid_refresh_token("lookup-refresh-token");
839
840 let outcome = match renewer
841 .renew_session(
842 AuthTokenState::NearExpiry {
843 expires_in: Duration::from_secs(15),
844 },
845 RenewalRequirement::Proactive,
846 &refresh_token,
847 now,
848 )
849 .await
850 {
851 Ok(outcome) => outcome,
852 Err(error) => panic!("expected renewal outcome: {error}"),
853 };
854
855 assert_eq!(
856 outcome,
857 RenewalOutcome::LeaseUnavailable {
858 acquisition: LeaseAcquisition::HeldByOther { active_lease },
859 }
860 );
861 }
862
863 #[tokio::test]
864 async fn renewer_revokes_family_on_refresh_token_replay() {
865 let repository = MockRepository::new();
866 let now = sample_time();
867 let lookup = sample_lookup(now);
868 {
869 let mut guard = repository.state.lock().await;
870 guard.lookup_result = Some(lookup.clone());
871 guard.lease_acquisition = Some(LeaseAcquisition::Acquired(sample_lease(
872 lookup.session.session_id,
873 now,
874 )));
875 guard.rotate_outcome = RotateRefreshTokenOutcome::RefreshTokenMismatch;
876 }
877
878 let renewer =
879 match SessionRenewer::new(valid_config(), repository.clone(), token_pair_issuer()) {
880 Ok(renewer) => renewer,
881 Err(error) => panic!("expected valid renewer configuration: {error}"),
882 };
883 let refresh_token = valid_refresh_token("lookup-refresh-token");
884
885 let outcome = match renewer
886 .renew_session(
887 AuthTokenState::Expired {
888 expired_for: Duration::from_secs(5),
889 },
890 RenewalRequirement::Required,
891 &refresh_token,
892 now,
893 )
894 .await
895 {
896 Ok(outcome) => outcome,
897 Err(error) => panic!("expected replay outcome: {error}"),
898 };
899
900 assert_eq!(
901 outcome,
902 RenewalOutcome::ReplayDetected {
903 session_id: lookup.session.session_id,
904 }
905 );
906 assert_eq!(
907 repository.state.lock().await.revoked_families,
908 vec![lookup.family.family_id]
909 );
910 }
911
912 #[tokio::test]
913 async fn revoker_maps_logout_scopes_to_repository_operations() {
914 let repository = MockRepository::new();
915 let revoker = SessionRevoker::new(repository.clone());
916 let session = sample_lookup(sample_time()).session;
917
918 let current_outcome = match revoker
919 .revoke_session(LogoutRequest::new(
920 session.session_id,
921 session.family_id,
922 LogoutScope::CurrentSession,
923 ))
924 .await
925 {
926 Ok(outcome) => outcome,
927 Err(error) => panic!("expected successful current-session revocation: {error}"),
928 };
929
930 let family_outcome = match revoker
931 .revoke_session(LogoutRequest::new(
932 session.session_id,
933 session.family_id,
934 LogoutScope::SessionFamily,
935 ))
936 .await
937 {
938 Ok(outcome) => outcome,
939 Err(error) => panic!("expected successful family revocation: {error}"),
940 };
941
942 let guard = repository.state.lock().await;
943
944 assert_eq!(current_outcome, LogoutOutcome::CurrentSessionRevoked);
945 assert_eq!(family_outcome, LogoutOutcome::SessionFamilyRevoked);
946 assert_eq!(
947 guard.revoked_sessions,
948 vec![(session.session_id, RevokeSessionScope::CurrentSession)]
949 );
950 assert_eq!(guard.revoked_families, vec![session.family_id]);
951 }
952}