@jpgilldev / steid

steid/src/application/session.rs
5.6 KBRaw
1use std::time::{Duration, SystemTime};
2
3use crate::domain::{
4 Actor, Session, SessionTokenHash, UserId,
5 repository::{RepositoryResult, SessionRepository},
6};
7
8use super::error::Result;
9
10/// How long a new session lasts.
11pub const SESSION_LIFETIME: Duration = Duration::from_secs(60 * 60 * 24 * 30);
12
13/// Records a session Topcoat has just issued.
14pub async fn record_session(
15 token_hash: SessionTokenHash,
16 user_id: UserId,
17 expires_at: SystemTime,
18 sessions: &impl SessionRepository,
19) -> Result<()> {
20 let session = Session::new(token_hash, user_id, expires_at);
21 sessions.save(&session).await?;
22 Ok(())
23}
24
25/// Resolves the actor behind a presented session token.
26///
27/// This is the authorization entry point: it is what decides whether a request is
28/// anonymous or authenticated, so it lives in a use case rather than a page. Anything
29/// unrecognised or expired resolves to [`Actor::Anonymous`] — it never fails open, and
30/// it never errors merely because a session is bad.
31pub async fn resolve_actor(
32 token_hash: Option<&SessionTokenHash>,
33 now: SystemTime,
34 sessions: &impl SessionRepository,
35) -> Result<Actor> {
36 let Some(token_hash) = token_hash else {
37 return Ok(Actor::Anonymous);
38 };
39
40 let Some(session) = sessions.find(token_hash).await? else {
41 return Ok(Actor::Anonymous);
42 };
43
44 if session.is_expired_at(now) {
45 // Expiry is enforced here, not left to the sweep, so a session that outlives
46 // a cleanup run still stops working on time.
47 return Ok(Actor::Anonymous);
48 }
49
50 Ok(Actor::User(session.user_id))
51}
52
53/// Forgets a session. Called on logout, alongside Topcoat discarding the cookie.
54pub async fn end_session(
55 token_hash: &SessionTokenHash,
56 sessions: &impl SessionRepository,
57) -> Result<()> {
58 sessions.delete(token_hash).await?;
59 Ok(())
60}
61
62/// Removes expired sessions. Safe to call at any time.
63pub async fn sweep_expired(
64 now: SystemTime,
65 sessions: &impl SessionRepository,
66) -> RepositoryResult<u64> {
67 sessions.delete_expired(now).await
68}
69
70#[cfg(test)]
71mod tests {
72 use super::*;
73 use crate::infrastructure::repository::InMemorySessionRepo;
74
75 fn at(seconds: u64) -> SystemTime {
76 SystemTime::UNIX_EPOCH + Duration::from_secs(seconds)
77 }
78
79 fn hash(value: &str) -> SessionTokenHash {
80 SessionTokenHash::from_trusted(value)
81 }
82
83 async fn repo_with_session(expires_at: SystemTime) -> (InMemorySessionRepo, UserId) {
84 let sessions = InMemorySessionRepo::new();
85 let user_id = UserId::generate();
86 record_session(hash("live"), user_id.clone(), expires_at, &sessions)
87 .await
88 .expect("record");
89 (sessions, user_id)
90 }
91
92 #[tokio::test]
93 async fn no_token_resolves_to_anonymous() {
94 let sessions = InMemorySessionRepo::new();
95
96 let actor = resolve_actor(None, at(100), &sessions)
97 .await
98 .expect("resolve");
99
100 assert_eq!(actor, Actor::Anonymous);
101 }
102
103 #[tokio::test]
104 async fn an_unknown_token_resolves_to_anonymous() {
105 let sessions = InMemorySessionRepo::new();
106
107 let actor = resolve_actor(Some(&hash("nope")), at(100), &sessions)
108 .await
109 .expect("resolve");
110
111 assert_eq!(
112 actor,
113 Actor::Anonymous,
114 "an unrecognised token must not error -- it must simply not authenticate"
115 );
116 }
117
118 #[tokio::test]
119 async fn a_live_session_resolves_to_its_user() {
120 let (sessions, user_id) = repo_with_session(at(1_000)).await;
121
122 let actor = resolve_actor(Some(&hash("live")), at(500), &sessions)
123 .await
124 .expect("resolve");
125
126 assert_eq!(actor, Actor::User(user_id));
127 }
128
129 #[tokio::test]
130 async fn an_expired_session_resolves_to_anonymous_even_before_a_sweep() {
131 let (sessions, _) = repo_with_session(at(1_000)).await;
132
133 let actor = resolve_actor(Some(&hash("live")), at(1_001), &sessions)
134 .await
135 .expect("resolve");
136
137 assert_eq!(
138 actor,
139 Actor::Anonymous,
140 "expiry must be enforced on read, not left to the cleanup job"
141 );
142 }
143
144 #[tokio::test]
145 async fn expiry_is_enforced_at_the_exact_boundary() {
146 let (sessions, _) = repo_with_session(at(1_000)).await;
147
148 let actor = resolve_actor(Some(&hash("live")), at(1_000), &sessions)
149 .await
150 .expect("resolve");
151
152 assert_eq!(actor, Actor::Anonymous);
153 }
154
155 #[tokio::test]
156 async fn ending_a_session_stops_it_resolving() {
157 let (sessions, _) = repo_with_session(at(1_000)).await;
158
159 end_session(&hash("live"), &sessions)
160 .await
161 .expect("end session");
162
163 let actor = resolve_actor(Some(&hash("live")), at(500), &sessions)
164 .await
165 .expect("resolve");
166
167 assert_eq!(actor, Actor::Anonymous);
168 }
169
170 #[tokio::test]
171 async fn sweeping_removes_only_expired_sessions() {
172 let sessions = InMemorySessionRepo::new();
173 record_session(hash("expired"), UserId::generate(), at(100), &sessions)
174 .await
175 .expect("record");
176 let live_user = UserId::generate();
177 record_session(hash("live"), live_user.clone(), at(10_000), &sessions)
178 .await
179 .expect("record");
180
181 let removed = sweep_expired(at(1_000), &sessions).await.expect("sweep");
182
183 assert_eq!(removed, 1);
184 assert_eq!(
185 resolve_actor(Some(&hash("live")), at(1_000), &sessions)
186 .await
187 .expect("resolve"),
188 Actor::User(live_user)
189 );
190 }
191}