Skip to main content

nythos_core/
session.rs

1//! Session lifecycle and refresh token concepts.
2//!
3//! This module contains the core session model and opaque refresh-token type
4//! used by refresh and revocation flows.
5
6use std::time::{Duration, SystemTime};
7
8use crate::{AuthError, NythosResult, SessionId, TenantId, UserId};
9
10/// Opaque refresh-token value used for session continuation.
11///
12/// This is intentionally not modeled as JWT claims or a transport payload.
13/// Rotation is handled by store/service logic around this type.
14#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
15pub struct RefreshToken(String);
16
17impl RefreshToken {
18    pub fn new(value: impl Into<String>) -> NythosResult<Self> {
19        let value = value.into();
20
21        if value.trim().is_empty() {
22            return Err(AuthError::ValidationError(
23                "refresh token cannot be empty".to_owned(),
24            ));
25        }
26
27        Ok(Self(value))
28    }
29
30    pub fn as_str(&self) -> &str {
31        &self.0
32    }
33
34    pub fn into_inner(self) -> String {
35        self.0
36    }
37}
38
39/// Authenticated session behind refresh and revocation flows.
40#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
41pub struct Session {
42    id: SessionId,
43    user_id: UserId,
44    tenant_id: TenantId,
45    issued_at: SystemTime,
46    expires_at: SystemTime,
47    revoked: bool,
48}
49
50impl Session {
51    pub fn new(
52        id: SessionId,
53        user_id: UserId,
54        tenant_id: TenantId,
55        issued_at: SystemTime,
56        expires_at: SystemTime,
57    ) -> NythosResult<Self> {
58        if expires_at <= issued_at {
59            return Err(AuthError::ValidationError(
60                "session expiration must be after issue time".to_owned(),
61            ));
62        }
63
64        Ok(Self {
65            id,
66            user_id,
67            tenant_id,
68            issued_at,
69            expires_at,
70            revoked: false,
71        })
72    }
73
74    pub fn with_ttl(
75        id: SessionId,
76        user_id: UserId,
77        tenant_id: TenantId,
78        issued_at: SystemTime,
79        ttl: Duration,
80    ) -> NythosResult<Self> {
81        let expires_at = issued_at.checked_add(ttl).ok_or_else(|| {
82            AuthError::ValidationError("session expiry overflowed system time".to_owned())
83        })?;
84
85        Self::new(id, user_id, tenant_id, issued_at, expires_at)
86    }
87
88    pub const fn id(&self) -> SessionId {
89        self.id
90    }
91
92    pub const fn user_id(&self) -> UserId {
93        self.user_id
94    }
95
96    pub const fn tenant_id(&self) -> TenantId {
97        self.tenant_id
98    }
99
100    pub const fn issued_at(&self) -> SystemTime {
101        self.issued_at
102    }
103
104    pub const fn expires_at(&self) -> SystemTime {
105        self.expires_at
106    }
107
108    pub const fn is_revoked(&self) -> bool {
109        self.revoked
110    }
111
112    pub fn revoke(&mut self) {
113        self.revoked = true;
114    }
115
116    pub fn is_expired_at(&self, now: SystemTime) -> bool {
117        self.expires_at <= now
118    }
119
120    pub fn is_active_at(&self, now: SystemTime) -> bool {
121        !self.revoked && !self.is_expired_at(now)
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::{RefreshToken, Session};
128    use crate::{AuthError, SessionId, TenantId, UserId};
129    use std::time::{Duration, SystemTime};
130
131    #[test]
132    fn refresh_token_requires_non_empty_value() {
133        assert!(matches!(
134            RefreshToken::new(""),
135            Err(AuthError::ValidationError(_))
136        ));
137
138        let token = RefreshToken::new("opaque-refresh-token").unwrap();
139        assert_eq!(token.as_str(), "opaque-refresh-token");
140    }
141
142    #[test]
143    fn session_requires_expiry_after_issue_time() {
144        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
145
146        let result = Session::new(
147            SessionId::generate(),
148            UserId::generate(),
149            TenantId::generate(),
150            now,
151            now,
152        );
153
154        assert!(matches!(result, Err(AuthError::ValidationError(_))));
155    }
156
157    #[test]
158    fn session_with_ttl_builds_active_session() {
159        let issued_at = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
160        let ttl = Duration::from_secs(3_600);
161
162        let session = Session::with_ttl(
163            SessionId::generate(),
164            UserId::generate(),
165            TenantId::generate(),
166            issued_at,
167            ttl,
168        )
169        .unwrap();
170
171        assert_eq!(session.issued_at(), issued_at);
172        assert_eq!(session.expires_at(), issued_at + ttl);
173        assert!(!session.is_revoked());
174        assert!(session.is_active_at(issued_at + Duration::from_secs(60)));
175    }
176
177    #[test]
178    fn session_expiry_helper_matches_expected_semantics() {
179        let issued_at = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
180        let session = Session::with_ttl(
181            SessionId::generate(),
182            UserId::generate(),
183            TenantId::generate(),
184            issued_at,
185            Duration::from_secs(60),
186        )
187        .unwrap();
188
189        assert!(!session.is_expired_at(issued_at + Duration::from_secs(59)));
190        assert!(session.is_expired_at(issued_at + Duration::from_secs(60)));
191    }
192
193    #[test]
194    fn revoked_session_is_no_longer_active() {
195        let issued_at = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
196        let mut session = Session::with_ttl(
197            SessionId::generate(),
198            UserId::generate(),
199            TenantId::generate(),
200            issued_at,
201            Duration::from_secs(600),
202        )
203        .unwrap();
204
205        assert!(session.is_active_at(issued_at + Duration::from_secs(1)));
206
207        session.revoke();
208
209        assert!(session.is_revoked());
210        assert!(!session.is_active_at(issued_at + Duration::from_secs(1)));
211    }
212}