1use std::time::Duration;
10
11use async_trait::async_trait;
12use base64::Engine;
13use base64::engine::general_purpose::URL_SAFE_NO_PAD;
14use chrono::{DateTime, TimeDelta, Utc};
15use puerto_core::RepositoryError;
16use puerto_core::auth::{
17 AuthError, Authenticator, CredentialStore, Principal, Session, SessionStore,
18};
19use sha2::{Digest, Sha256};
20use sqlx::PgPool;
21use uuid::Uuid;
22
23use crate::map_sqlx_error;
24
25pub const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(30 * 24 * 3600);
27
28pub fn generate_token() -> Result<String, getrandom::Error> {
30 let mut bytes = [0u8; 32];
31 getrandom::fill(&mut bytes)?;
32 Ok(URL_SAFE_NO_PAD.encode(bytes))
33}
34
35pub fn token_hash(token: &str) -> [u8; 32] {
37 Sha256::digest(token.as_bytes()).into()
38}
39
40pub fn session_ttl_from_env() -> Result<Duration, String> {
43 let _ = dotenvy::dotenv();
44 match std::env::var("AUTH_SESSION_TTL") {
45 Err(_) => Ok(DEFAULT_SESSION_TTL),
46 Ok(value) => parse_ttl(&value).ok_or_else(|| {
47 format!("AUTH_SESSION_TTL={value} is not a duration: use days, hours or minutes (30d, 12h, 90m)")
48 }),
49 }
50}
51
52pub fn parse_ttl(value: &str) -> Option<Duration> {
54 let value = value.trim();
55 let unit = match value.chars().last()? {
56 'd' => 24 * 3600,
57 'h' => 3600,
58 'm' => 60,
59 _ => return None,
60 };
61 let n: u64 = value[..value.len() - 1].parse().ok()?;
62 if n == 0 {
63 return None;
64 }
65 n.checked_mul(unit).map(Duration::from_secs)
66}
67
68pub struct PgSessions {
72 pool: PgPool,
73 ttl: TimeDelta,
74}
75
76impl PgSessions {
77 pub fn new(pool: PgPool, ttl: Duration) -> Self {
78 Self {
79 pool,
80 ttl: TimeDelta::from_std(ttl).unwrap_or(TimeDelta::MAX),
81 }
82 }
83}
84
85type SessionUser = (
87 Uuid,
88 String,
89 Option<String>,
90 Option<String>,
91 Vec<String>,
92 DateTime<Utc>,
93);
94
95#[async_trait]
96impl SessionStore for PgSessions {
97 async fn open(&self, user_id: Uuid) -> Result<Session, RepositoryError> {
98 let token = generate_token().map_err(|err| {
99 tracing::error!(error = %err, "sessions: no randomness for a token");
100 RepositoryError::Unavailable
101 })?;
102 let expires_at = Utc::now() + self.ttl;
103 sqlx::query("INSERT INTO sessions (token_hash, user_id, expires_at) VALUES ($1, $2, $3)")
104 .bind(&token_hash(&token)[..])
105 .bind(user_id)
106 .bind(expires_at)
107 .execute(&self.pool)
108 .await
109 .map_err(|err| {
110 tracing::error!(error = %err, %user_id, "sessions: open failed");
111 map_sqlx_error(&err)
112 })?;
113 Ok(Session::new(token, expires_at))
114 }
115
116 async fn revoke(&self, token: &str) -> Result<(), RepositoryError> {
117 sqlx::query("DELETE FROM sessions WHERE token_hash = $1")
118 .bind(&token_hash(token)[..])
119 .execute(&self.pool)
120 .await
121 .map(|_| ())
122 .map_err(|err| {
123 tracing::error!(error = %err, "sessions: revoke failed");
124 map_sqlx_error(&err)
125 })
126 }
127}
128
129#[async_trait]
130impl Authenticator for PgSessions {
131 async fn authenticate(&self, token: &str) -> Result<Principal, AuthError> {
132 let row = sqlx::query_as::<_, SessionUser>(
133 "SELECT u.id, u.subject, u.email, u.name, u.roles, s.expires_at \
134 FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = $1",
135 )
136 .bind(&token_hash(token)[..])
137 .fetch_optional(&self.pool)
138 .await
139 .map_err(|err| {
140 tracing::error!(error = %err, "sessions: lookup failed");
141 AuthError::Unavailable
142 })?
143 .ok_or(AuthError::Invalid)?;
144 let (id, subject, email, name, roles, expires_at) = row;
145 if expires_at <= Utc::now() {
146 return Err(AuthError::Expired);
147 }
148 let mut principal = Principal::new(id, subject).with_roles(roles);
149 if let Some(email) = email {
150 principal = principal.with_email(email);
151 }
152 if let Some(name) = name {
153 principal = principal.with_name(name);
154 }
155 Ok(principal)
156 }
157}
158
159pub struct PgCredentials {
161 pool: PgPool,
162}
163
164impl PgCredentials {
165 pub fn new(pool: PgPool) -> Self {
166 Self { pool }
167 }
168}
169
170#[async_trait]
171impl CredentialStore for PgCredentials {
172 async fn password_hash(&self, user_id: Uuid) -> Result<Option<String>, RepositoryError> {
173 sqlx::query_scalar("SELECT password_hash FROM credentials WHERE user_id = $1")
174 .bind(user_id)
175 .fetch_optional(&self.pool)
176 .await
177 .map_err(|err| {
178 tracing::error!(error = %err, %user_id, "credentials: read failed");
179 map_sqlx_error(&err)
180 })
181 }
182
183 async fn set_password_hash(&self, user_id: Uuid, hash: &str) -> Result<(), RepositoryError> {
184 sqlx::query(
185 "INSERT INTO credentials (user_id, password_hash) VALUES ($1, $2) \
186 ON CONFLICT (user_id) DO UPDATE SET password_hash = EXCLUDED.password_hash, \
187 updated_at = now()",
188 )
189 .bind(user_id)
190 .bind(hash)
191 .execute(&self.pool)
192 .await
193 .map(|_| ())
194 .map_err(|err| {
195 tracing::error!(error = %err, %user_id, "credentials: write failed");
196 map_sqlx_error(&err)
197 })
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn should_make_a_new_url_safe_token_of_256_bits_each_time() {
207 let first = generate_token().unwrap();
209 let second = generate_token().unwrap();
210
211 assert_eq!(first.len(), 43);
213 assert!(
214 first
215 .chars()
216 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
217 );
218 assert_ne!(first, second);
219 }
220
221 #[test]
222 fn should_hash_a_token_the_same_way_every_time() {
223 assert_eq!(token_hash("abc"), token_hash("abc"));
224 assert_ne!(token_hash("abc"), token_hash("abd"));
225 }
226
227 #[test]
228 fn should_read_days_hours_and_minutes() {
229 assert_eq!(parse_ttl("30d"), Some(Duration::from_secs(30 * 24 * 3600)));
230 assert_eq!(parse_ttl("12h"), Some(Duration::from_secs(12 * 3600)));
231 assert_eq!(parse_ttl(" 90m "), Some(Duration::from_secs(90 * 60)));
232 }
233
234 #[test]
235 fn should_refuse_a_ttl_without_a_unit_a_zero_or_an_overflow() {
236 for value in [
237 "30",
238 "0d",
239 "-1d",
240 "d",
241 "",
242 "1w",
243 "1.5d",
244 "99999999999999999999d",
245 ] {
246 assert_eq!(parse_ttl(value), None, "{value}");
247 }
248 }
249}