1use chrono::{DateTime, Utc};
2use platform_core::{ActorContext, ActorResolutionRequest, ActorResolver, AppResult, DbPool};
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::fmt::Write as _;
6use std::sync::Arc;
7
8const SESSION_COOKIE: &str = "lenso_session";
9
10#[derive(Debug, Clone)]
11pub struct AuthActorResolver {
12 pool: DbPool,
13 fallback: Arc<dyn ActorResolver>,
14 session_cache: Option<Arc<dyn SessionCache>>,
15}
16
17impl AuthActorResolver {
18 #[must_use]
19 pub fn new(pool: DbPool, fallback: Arc<dyn ActorResolver>) -> Self {
20 Self {
21 pool,
22 fallback,
23 session_cache: None,
24 }
25 }
26
27 #[must_use]
28 pub fn new_with_session_cache(
29 pool: DbPool,
30 fallback: Arc<dyn ActorResolver>,
31 session_cache: Option<Arc<dyn SessionCache>>,
32 ) -> Self {
33 Self {
34 pool,
35 fallback,
36 session_cache,
37 }
38 }
39
40 async fn resolve_session_token(&self, token: &str) -> AppResult<Option<String>> {
41 let token_hash = session_token_hash(token);
42 if let Some(cache) = &self.session_cache {
43 match cache.get(&token_hash).await {
44 Ok(Some(session)) if session.expires_at > Utc::now() => {
45 return Ok(Some(session.user_id));
46 }
47 Ok(Some(_)) => {
48 if let Err(error) = cache.delete(&token_hash).await {
49 tracing::warn!(error = ?error, "failed to delete expired auth session cache");
50 }
51 }
52 Ok(None) => {}
53 Err(error) => {
54 tracing::warn!(error = ?error, "failed to read auth session cache");
55 }
56 }
57 }
58
59 let row = sqlx::query_as::<_, (String, DateTime<Utc>)>(
60 r#"
61 select users.id, sessions.expires_at
62 from auth.sessions sessions
63 join auth.users users on users.id = sessions.user_id
64 where sessions.token_hash = $1
65 and sessions.expires_at > now()
66 and sessions.revoked_at is null
67 and (users.disabled_at is null or users.disabled_until <= now())
68 limit 1
69 "#,
70 )
71 .bind(&token_hash)
72 .fetch_optional(&self.pool)
73 .await
74 .map_err(|source| {
75 platform_core::AppError::new(
76 platform_core::ErrorCode::Internal,
77 "Failed to resolve auth session",
78 )
79 .with_source(source)
80 })?;
81
82 if let Some((user_id, expires_at)) = row {
83 if let Some(cache) = &self.session_cache {
84 let session = CachedSession {
85 user_id: user_id.clone(),
86 expires_at,
87 };
88 if let Err(error) = cache.put(&token_hash, session).await {
89 tracing::warn!(error = ?error, "failed to write auth session cache");
90 }
91 }
92 return Ok(Some(user_id));
93 }
94
95 Ok(None)
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100pub struct CachedSession {
101 pub user_id: String,
102 pub expires_at: DateTime<Utc>,
103}
104
105#[async_trait::async_trait]
106pub trait SessionCache: std::fmt::Debug + Send + Sync {
107 async fn get(&self, token_hash: &str) -> AppResult<Option<CachedSession>>;
108 async fn put(&self, token_hash: &str, session: CachedSession) -> AppResult<()>;
109 async fn delete(&self, token_hash: &str) -> AppResult<()>;
110}
111
112#[async_trait::async_trait]
113impl ActorResolver for AuthActorResolver {
114 async fn resolve_actor(&self, request: ActorResolutionRequest) -> ActorContext {
115 let tokens = session_tokens(&request);
116 for token in tokens {
117 match self.resolve_session_token(&token).await {
118 Ok(Some(user_id)) => {
119 return ActorContext::User {
120 user_id,
121 scopes: Vec::new(),
122 };
123 }
124 Ok(None) => {}
125 Err(error) => {
126 tracing::warn!(error = ?error, "failed to resolve auth session");
127 }
128 }
129 }
130
131 self.fallback.resolve_actor(request).await
132 }
133}
134
135pub fn session_token_hash(token: &str) -> String {
136 let digest = Sha256::digest(token.as_bytes());
137 let mut encoded = String::with_capacity("sha256:".len() + digest.len() * 2);
138 encoded.push_str("sha256:");
139 for byte in digest {
140 let _ = write!(encoded, "{byte:02x}");
141 }
142 encoded
143}
144
145pub fn first_session_token(request: &ActorResolutionRequest) -> Option<String> {
146 session_tokens(request).into_iter().next()
147}
148
149fn session_tokens(request: &ActorResolutionRequest) -> Vec<String> {
150 let mut tokens = Vec::new();
151 if let Some(token) = request
152 .authorization
153 .as_deref()
154 .and_then(bearer_token)
155 .map(ToOwned::to_owned)
156 {
157 tokens.push(token);
158 }
159 if let Some(token) = request.cookie.as_deref().and_then(session_cookie) {
160 tokens.push(token);
161 }
162 tokens
163}
164
165fn bearer_token(header: &str) -> Option<&str> {
166 header
167 .strip_prefix("Bearer ")
168 .map(str::trim)
169 .filter(|token| !token.is_empty())
170 .filter(|token| !token.starts_with("dev-user:") && !token.starts_with("dev-service:"))
171}
172
173fn session_cookie(header: &str) -> Option<String> {
174 header.split(';').find_map(|part| {
175 let (name, value) = part.trim().split_once('=')?;
176 (name == SESSION_COOKIE)
177 .then(|| value.trim())
178 .filter(|value| !value.is_empty())
179 .map(ToOwned::to_owned)
180 })
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use std::collections::HashMap;
187 use std::sync::Mutex;
188
189 #[test]
190 fn session_token_hash_is_sha256_hex() {
191 assert_eq!(
192 session_token_hash("session-secret"),
193 "sha256:135fa7d67146ca540f17e51e101d45de5b1edde8ea8a13f9c7b93b71aa21f8a4"
194 );
195 }
196
197 #[test]
198 fn extracts_bearer_and_cookie_session_tokens() {
199 let request = ActorResolutionRequest {
200 authorization: Some("Bearer bearer-token".to_owned()),
201 cookie: Some("theme=dark; lenso_session=cookie-token".to_owned()),
202 };
203
204 assert_eq!(
205 session_tokens(&request),
206 vec!["bearer-token".to_owned(), "cookie-token".to_owned()]
207 );
208 }
209
210 #[test]
211 fn leaves_dev_bearer_tokens_for_fallback_resolver() {
212 let request = ActorResolutionRequest {
213 authorization: Some("Bearer dev-user:user_123".to_owned()),
214 cookie: None,
215 };
216
217 assert!(session_tokens(&request).is_empty());
218 }
219
220 #[tokio::test]
221 async fn cache_hit_resolves_user_without_database() {
222 let token_hash = session_token_hash("cached-token");
223 let cache = Arc::new(FakeSessionCache::new([(
224 token_hash,
225 CachedSession {
226 user_id: "usr_cached".to_owned(),
227 expires_at: Utc::now() + chrono::Duration::hours(1),
228 },
229 )]));
230 let resolver = AuthActorResolver::new_with_session_cache(
231 DbPool::connect_lazy("postgres://localhost/unused").expect("lazy pool"),
232 Arc::new(AnonymousResolver),
233 Some(cache.clone()),
234 );
235
236 let actor = resolver
237 .resolve_actor(ActorResolutionRequest {
238 authorization: Some("Bearer cached-token".to_owned()),
239 cookie: None,
240 })
241 .await;
242
243 match actor {
244 ActorContext::User { user_id, scopes } => {
245 assert_eq!(user_id, "usr_cached");
246 assert!(scopes.is_empty());
247 }
248 other => panic!("expected cached user actor, got {other:?}"),
249 }
250 assert_eq!(*cache.gets.lock().expect("gets"), 1);
251 }
252
253 #[derive(Debug)]
254 struct AnonymousResolver;
255
256 #[async_trait::async_trait]
257 impl ActorResolver for AnonymousResolver {
258 async fn resolve_actor(&self, _request: ActorResolutionRequest) -> ActorContext {
259 ActorContext::Anonymous
260 }
261 }
262
263 #[derive(Debug)]
264 struct FakeSessionCache {
265 values: Mutex<HashMap<String, CachedSession>>,
266 gets: Mutex<usize>,
267 }
268
269 impl FakeSessionCache {
270 fn new(entries: impl IntoIterator<Item = (String, CachedSession)>) -> Self {
271 Self {
272 values: Mutex::new(entries.into_iter().collect()),
273 gets: Mutex::new(0),
274 }
275 }
276 }
277
278 #[async_trait::async_trait]
279 impl SessionCache for FakeSessionCache {
280 async fn get(&self, token_hash: &str) -> AppResult<Option<CachedSession>> {
281 *self.gets.lock().expect("gets") += 1;
282 Ok(self.values.lock().expect("values").get(token_hash).cloned())
283 }
284
285 async fn put(&self, token_hash: &str, session: CachedSession) -> AppResult<()> {
286 self.values
287 .lock()
288 .expect("values")
289 .insert(token_hash.to_owned(), session);
290 Ok(())
291 }
292
293 async fn delete(&self, token_hash: &str) -> AppResult<()> {
294 self.values.lock().expect("values").remove(token_hash);
295 Ok(())
296 }
297 }
298}