Skip to main content

auth/
resolver.rs

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}