Skip to main content

puerto_sqlx/
auth.rs

1//! Local accounts (ADR-0021 § 2): the sessions `login` opens and the password hashes it checks.
2//! Their tables are [`crate::USERS_MIGRATION`] and [`crate::AUTH_MIGRATION`], which
3//! `puerto new --auth local` writes into the project.
4//!
5//! A session is an opaque token — 32 random bytes, base64url — and its row keeps only the token's
6//! SHA-256. Revoking is a `DELETE`; there is no signing key to keep or rotate, and a lookup per
7//! request is the cost.
8
9use 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
25/// How long a session lasts when `AUTH_SESSION_TTL` is not set.
26pub const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(30 * 24 * 3600);
27
28/// A new session token: 256 random bits, base64url without padding (43 characters).
29pub 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
35/// What a session row keeps of its token.
36pub fn token_hash(token: &str) -> [u8; 32] {
37    Sha256::digest(token.as_bytes()).into()
38}
39
40/// `AUTH_SESSION_TTL` — `30d`, `12h`, `90m` — or [`DEFAULT_SESSION_TTL`] when it is not set. A set
41/// but invalid value is an error: a startup error, not a silent default.
42pub 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
52/// `<n>d`, `<n>h` or `<n>m`, `n` > 0.
53pub 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
68/// `sessions`: a [`SessionStore`] for `login` and `logout`, and the [`Authenticator`] of a local
69/// project — the presented token's hash, joined with its user. The roles are the `users.roles`
70/// column (`make auth/grant`).
71pub 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
85/// A session's user: id, subject, email, name, roles, and the session's expiry.
86type 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
159/// `credentials`: one password hash per user.
160pub 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        // Act
208        let first = generate_token().unwrap();
209        let second = generate_token().unwrap();
210
211        // Assert
212        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}