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}