Skip to main content

auth/
resolver.rs

1use platform_core::{ActorContext, ActorResolutionRequest, ActorResolver, AppResult, DbPool};
2use sha2::{Digest, Sha256};
3use std::fmt::Write as _;
4use std::sync::Arc;
5
6const SESSION_COOKIE: &str = "lenso_session";
7
8#[derive(Debug, Clone)]
9pub struct AuthActorResolver {
10    pool: DbPool,
11    fallback: Arc<dyn ActorResolver>,
12}
13
14impl AuthActorResolver {
15    #[must_use]
16    pub fn new(pool: DbPool, fallback: Arc<dyn ActorResolver>) -> Self {
17        Self { pool, fallback }
18    }
19
20    async fn resolve_session_token(&self, token: &str) -> AppResult<Option<String>> {
21        let token_hash = session_token_hash(token);
22        sqlx::query_scalar::<_, String>(
23            r#"
24            select users.id
25            from auth.sessions sessions
26            join auth.users users on users.id = sessions.user_id
27            where sessions.token_hash = $1
28              and sessions.expires_at > now()
29              and sessions.revoked_at is null
30              and users.disabled_at is null
31            limit 1
32            "#,
33        )
34        .bind(token_hash)
35        .fetch_optional(&self.pool)
36        .await
37        .map_err(|source| {
38            platform_core::AppError::new(
39                platform_core::ErrorCode::Internal,
40                "Failed to resolve auth session",
41            )
42            .with_source(source)
43        })
44    }
45}
46
47#[async_trait::async_trait]
48impl ActorResolver for AuthActorResolver {
49    async fn resolve_actor(&self, request: ActorResolutionRequest) -> ActorContext {
50        let tokens = session_tokens(&request);
51        for token in tokens {
52            match self.resolve_session_token(&token).await {
53                Ok(Some(user_id)) => {
54                    return ActorContext::User {
55                        user_id,
56                        scopes: Vec::new(),
57                    };
58                }
59                Ok(None) => {}
60                Err(error) => {
61                    tracing::warn!(error = ?error, "failed to resolve auth session");
62                }
63            }
64        }
65
66        self.fallback.resolve_actor(request).await
67    }
68}
69
70pub fn session_token_hash(token: &str) -> String {
71    let digest = Sha256::digest(token.as_bytes());
72    let mut encoded = String::with_capacity("sha256:".len() + digest.len() * 2);
73    encoded.push_str("sha256:");
74    for byte in digest {
75        let _ = write!(encoded, "{byte:02x}");
76    }
77    encoded
78}
79
80pub fn first_session_token(request: &ActorResolutionRequest) -> Option<String> {
81    session_tokens(request).into_iter().next()
82}
83
84fn session_tokens(request: &ActorResolutionRequest) -> Vec<String> {
85    let mut tokens = Vec::new();
86    if let Some(token) = request
87        .authorization
88        .as_deref()
89        .and_then(bearer_token)
90        .map(ToOwned::to_owned)
91    {
92        tokens.push(token);
93    }
94    if let Some(token) = request.cookie.as_deref().and_then(session_cookie) {
95        tokens.push(token);
96    }
97    tokens
98}
99
100fn bearer_token(header: &str) -> Option<&str> {
101    header
102        .strip_prefix("Bearer ")
103        .map(str::trim)
104        .filter(|token| !token.is_empty())
105        .filter(|token| !token.starts_with("dev-user:") && !token.starts_with("dev-service:"))
106}
107
108fn session_cookie(header: &str) -> Option<String> {
109    header.split(';').find_map(|part| {
110        let (name, value) = part.trim().split_once('=')?;
111        (name == SESSION_COOKIE)
112            .then(|| value.trim())
113            .filter(|value| !value.is_empty())
114            .map(ToOwned::to_owned)
115    })
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn session_token_hash_is_sha256_hex() {
124        assert_eq!(
125            session_token_hash("session-secret"),
126            "sha256:135fa7d67146ca540f17e51e101d45de5b1edde8ea8a13f9c7b93b71aa21f8a4"
127        );
128    }
129
130    #[test]
131    fn extracts_bearer_and_cookie_session_tokens() {
132        let request = ActorResolutionRequest {
133            authorization: Some("Bearer bearer-token".to_owned()),
134            cookie: Some("theme=dark; lenso_session=cookie-token".to_owned()),
135        };
136
137        assert_eq!(
138            session_tokens(&request),
139            vec!["bearer-token".to_owned(), "cookie-token".to_owned()]
140        );
141    }
142
143    #[test]
144    fn leaves_dev_bearer_tokens_for_fallback_resolver() {
145        let request = ActorResolutionRequest {
146            authorization: Some("Bearer dev-user:user_123".to_owned()),
147            cookie: None,
148        };
149
150        assert!(session_tokens(&request).is_empty());
151    }
152}