1use crate::lease::LeaseAcquisition;
10use crate::lease::RenewalLease;
11use crate::session::SessionFamilyId;
12use crate::session::SessionFamilyRecord;
13use crate::session::SessionId;
14use crate::session::SessionLookup;
15use crate::session::SessionRecord;
16use crate::session::SessionRefreshRecord;
17use crate::session::SessionTouch;
18use crate::tokens::RefreshTokenHash;
19use crate::tokens::RefreshTokenHashRef;
20
21pub type RepositoryResult<T> = std::result::Result<T, RepositoryError>;
23
24#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
26pub enum RepositoryError {
27 #[error("session not found")]
29 SessionNotFound,
30
31 #[error("session family not found")]
33 SessionFamilyNotFound,
34
35 #[error("concurrent session update detected")]
37 Conflict,
38
39 #[error("invalid persisted session state")]
41 InvalidState,
42
43 #[error("{message}")]
45 Backend {
46 message: String,
48 },
49}
50
51impl RepositoryError {
52 #[must_use]
54 pub fn backend(message: impl Into<String>) -> Self {
55 Self::Backend {
56 message: message.into(),
57 }
58 }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct CreateSession {
90 pub session: SessionRecord,
92 pub refresh_token_hash: RefreshTokenHash,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct RotateRefreshToken {
136 pub session_id: SessionId,
138 pub family: SessionFamilyRecord,
140 pub lease: RenewalLease,
142 pub previous_refresh_token_hash: RefreshTokenHash,
144 pub next_refresh_token_hash: RefreshTokenHash,
146 pub next_session: SessionRecord,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum RotateRefreshTokenOutcome {
153 Rotated,
155 SessionMissing,
157 LeaseUnavailable,
159 RefreshTokenMismatch,
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum RevokeSessionScope {
169 CurrentSession,
171 SessionFamily,
173}
174
175pub trait SessionRepository: Send + Sync {
186 fn create_session(
188 &self,
189 input: CreateSession,
190 ) -> impl std::future::Future<Output = RepositoryResult<()>> + Send;
191
192 fn find_session_by_refresh_token_hash<'a>(
194 &'a self,
195 refresh_token_hash: RefreshTokenHashRef<'a>,
196 ) -> impl std::future::Future<Output = RepositoryResult<Option<SessionLookup>>> + Send + 'a;
197
198 fn find_session(
200 &self,
201 session_id: SessionId,
202 ) -> impl std::future::Future<Output = RepositoryResult<Option<SessionRecord>>> + Send;
203
204 fn find_family(
206 &self,
207 family_id: SessionFamilyId,
208 ) -> impl std::future::Future<Output = RepositoryResult<Option<SessionFamilyRecord>>> + Send;
209
210 fn find_refresh_record(
212 &self,
213 session_id: SessionId,
214 ) -> impl std::future::Future<Output = RepositoryResult<Option<SessionRefreshRecord>>> + Send;
215
216 fn try_acquire_renewal_lease(
218 &self,
219 session_id: SessionId,
220 lease: RenewalLease,
221 ) -> impl std::future::Future<Output = RepositoryResult<LeaseAcquisition>> + Send;
222
223 fn rotate_refresh_token(
225 &self,
226 input: RotateRefreshToken,
227 ) -> impl std::future::Future<Output = RepositoryResult<RotateRefreshTokenOutcome>> + Send;
228
229 fn revoke_session(
231 &self,
232 session_id: SessionId,
233 scope: RevokeSessionScope,
234 ) -> impl std::future::Future<Output = RepositoryResult<()>> + Send;
235
236 fn revoke_family(
238 &self,
239 family_id: SessionFamilyId,
240 ) -> impl std::future::Future<Output = RepositoryResult<()>> + Send;
241
242 fn touch_session(
244 &self,
245 touch: SessionTouch,
246 ) -> impl std::future::Future<Output = RepositoryResult<()>> + Send;
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use crate::lease::{LeaseAcquisition, LeaseId, LeaseTtl, RenewalLease};
253 use crate::session::{Session, SessionFamilyRecord};
254 use std::time::{Duration, SystemTime};
255
256 #[derive(Debug, Clone, Copy, Default)]
257 struct ContractRepository;
258
259 impl SessionRepository for ContractRepository {
260 async fn create_session(&self, _input: CreateSession) -> RepositoryResult<()> {
261 Ok(())
262 }
263
264 async fn find_session_by_refresh_token_hash<'a>(
265 &'a self,
266 _refresh_token_hash: RefreshTokenHashRef<'a>,
267 ) -> RepositoryResult<Option<SessionLookup>> {
268 Ok(None)
269 }
270
271 async fn find_session(
272 &self,
273 _session_id: SessionId,
274 ) -> RepositoryResult<Option<SessionRecord>> {
275 Ok(None)
276 }
277
278 async fn find_family(
279 &self,
280 _family_id: SessionFamilyId,
281 ) -> RepositoryResult<Option<SessionFamilyRecord>> {
282 Ok(None)
283 }
284
285 async fn find_refresh_record(
286 &self,
287 _session_id: SessionId,
288 ) -> RepositoryResult<Option<SessionRefreshRecord>> {
289 Ok(None)
290 }
291
292 async fn try_acquire_renewal_lease(
293 &self,
294 _session_id: SessionId,
295 lease: RenewalLease,
296 ) -> RepositoryResult<LeaseAcquisition> {
297 Ok(LeaseAcquisition::Acquired(lease))
298 }
299
300 async fn rotate_refresh_token(
301 &self,
302 _input: RotateRefreshToken,
303 ) -> RepositoryResult<RotateRefreshTokenOutcome> {
304 Ok(RotateRefreshTokenOutcome::Rotated)
305 }
306
307 async fn revoke_session(
308 &self,
309 _session_id: SessionId,
310 _scope: RevokeSessionScope,
311 ) -> RepositoryResult<()> {
312 Ok(())
313 }
314
315 async fn revoke_family(&self, _family_id: SessionFamilyId) -> RepositoryResult<()> {
316 Ok(())
317 }
318
319 async fn touch_session(&self, _touch: SessionTouch) -> RepositoryResult<()> {
320 Ok(())
321 }
322 }
323
324 fn assert_session_repository<T: SessionRepository>(_repository: &T) {}
325
326 fn sample_time() -> SystemTime {
327 SystemTime::UNIX_EPOCH + Duration::from_secs(1_000)
328 }
329
330 fn sample_hash(value: &str) -> RefreshTokenHash {
331 match RefreshTokenHash::new(value) {
332 Ok(hash) => hash,
333 Err(error) => panic!("expected valid refresh-token hash: {error}"),
334 }
335 }
336
337 fn sample_session() -> SessionRecord {
338 let now = sample_time();
339
340 Session::new(
341 SessionFamilyId::new(),
342 "subject-123",
343 now,
344 now + Duration::from_secs(3_600),
345 )
346 }
347
348 fn sample_lease(session_id: SessionId) -> RenewalLease {
349 RenewalLease::from_ttl(
350 session_id,
351 LeaseId::new(),
352 sample_time(),
353 LeaseTtl::new(Duration::from_secs(30)),
354 )
355 }
356
357 #[test]
358 fn backend_error_constructor_keeps_message() {
359 let error = RepositoryError::backend("safe backend summary");
360
361 assert_eq!(
362 error,
363 RepositoryError::Backend {
364 message: String::from("safe backend summary"),
365 }
366 );
367 }
368
369 #[tokio::test]
370 async fn repository_trait_contracts_are_callable() {
371 let repository = ContractRepository;
372 assert_session_repository(&repository);
373
374 let session = sample_session();
375 let family =
376 SessionFamilyRecord::new(session.family_id, session.subject_id.clone(), sample_time());
377 let lease = sample_lease(session.session_id);
378 let create_input = CreateSession {
379 session: session.clone(),
380 refresh_token_hash: sample_hash("active-refresh-hash"),
381 };
382 let rotate_input = RotateRefreshToken {
383 session_id: session.session_id,
384 family,
385 lease,
386 previous_refresh_token_hash: sample_hash("previous-refresh-hash"),
387 next_refresh_token_hash: sample_hash("next-refresh-hash"),
388 next_session: session
389 .clone()
390 .touched(sample_time() + Duration::from_secs(10)),
391 };
392 let touch = SessionTouch::new(session.session_id, sample_time() + Duration::from_secs(20));
393
394 assert_eq!(repository.create_session(create_input).await, Ok(()));
395 assert!(matches!(
396 repository
397 .find_session_by_refresh_token_hash("lookup-refresh-hash")
398 .await,
399 Ok(None)
400 ));
401 assert!(matches!(
402 repository.find_session(session.session_id).await,
403 Ok(None)
404 ));
405 assert!(matches!(
406 repository.find_family(session.family_id).await,
407 Ok(None)
408 ));
409 assert!(matches!(
410 repository.find_refresh_record(session.session_id).await,
411 Ok(None)
412 ));
413 assert_eq!(
414 repository
415 .try_acquire_renewal_lease(session.session_id, lease)
416 .await,
417 Ok(LeaseAcquisition::Acquired(lease))
418 );
419 assert_eq!(
420 repository.rotate_refresh_token(rotate_input).await,
421 Ok(RotateRefreshTokenOutcome::Rotated)
422 );
423 assert_eq!(
424 repository
425 .revoke_session(session.session_id, RevokeSessionScope::CurrentSession)
426 .await,
427 Ok(())
428 );
429 assert_eq!(repository.revoke_family(session.family_id).await, Ok(()));
430 assert_eq!(repository.touch_session(touch).await, Ok(()));
431 }
432}