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