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::fmt::Write as _;
6use std::sync::Arc;
7
8pub const SESSION_COOKIE_NAME: &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_NAME)
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}