Skip to main content

modo/auth/session/jwt/
service.rs

1//! [`JwtSessionService`] — stateful JWT session lifecycle management.
2//!
3//! Wraps [`SessionStore`](crate::auth::session::store::SessionStore) and the
4//! JWT encoder/decoder to provide a high-level API for issuing, rotating, and
5//! revoking JWT sessions backed by a database row.
6
7use std::sync::Arc;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use crate::auth::session::Session;
11use crate::auth::session::meta::SessionMeta;
12use crate::auth::session::store::SessionStore;
13use crate::auth::session::token::SessionToken;
14use crate::db::Database;
15use crate::{Error, Result};
16
17use super::claims::Claims;
18use super::config::JwtSessionsConfig;
19use super::decoder::JwtDecoder;
20use super::encoder::JwtEncoder;
21use super::tokens::TokenPair;
22
23/// Audience value embedded in access tokens.
24const AUD_ACCESS: &str = "access";
25/// Audience value embedded in refresh tokens.
26const AUD_REFRESH: &str = "refresh";
27
28fn now_secs() -> u64 {
29    SystemTime::now()
30        .duration_since(UNIX_EPOCH)
31        .expect("system clock before UNIX epoch")
32        .as_secs()
33}
34
35/// Stateful JWT session service.
36///
37/// Manages the full lifecycle of JWT-based sessions backed by a SQLite session
38/// table. Each session is represented as a database row — the `jti` claim in
39/// both the access and refresh tokens contains the hex-encoded session token,
40/// which is hashed before storage.
41///
42/// Cloning is cheap — all state is behind `Arc`.
43///
44/// # Session lifecycle
45///
46/// 1. **`authenticate`** — creates a new session row, issues an access + refresh token pair.
47/// 2. **`rotate`** — validates the refresh token, rotates the stored token hash, issues a new pair.
48/// 3. **`logout`** — validates the access token, destroys the session row.
49///
50/// # Wiring
51///
52/// ```rust,ignore
53/// let config = JwtSessionsConfig::new("my-super-secret-key-for-signing-tokens");
54/// let svc = JwtSessionService::new(db, config)?;
55///
56/// // Authenticate a user (e.g. after password check)
57/// let meta = SessionMeta::from_headers(ip, user_agent, accept_language, accept_encoding);
58/// let pair = svc.authenticate("user_123", &meta).await?;
59///
60/// // Rotate (called from the refresh endpoint)
61/// let new_pair = svc.rotate(&pair.refresh_token).await?;
62///
63/// // Logout (called from the logout endpoint)
64/// svc.logout(&pair.access_token).await?;
65/// ```
66#[derive(Clone)]
67pub struct JwtSessionService {
68    inner: Arc<Inner>,
69}
70
71struct Inner {
72    store: SessionStore,
73    encoder: JwtEncoder,
74    decoder: JwtDecoder,
75    config: JwtSessionsConfig,
76}
77
78impl JwtSessionService {
79    /// Create a new `JwtSessionService`.
80    ///
81    /// Validates that `signing_secret` is non-empty and builds the encoder,
82    /// decoder, and session store. Returns an error immediately if the config
83    /// is invalid — fail fast at startup.
84    ///
85    /// # Errors
86    ///
87    /// Returns `Error::internal` if `signing_secret` is empty.
88    pub fn new(db: Database, config: JwtSessionsConfig) -> Result<Self> {
89        if config.signing_secret.is_empty() {
90            return Err(Error::internal("jwt: signing_secret must be set"));
91        }
92
93        let encoder = JwtEncoder::from_config(&config);
94        let decoder = JwtDecoder::from_config(&config);
95
96        // Build a CookieSessionsConfig to drive the store TTL and eviction policy.
97        let store_cfg = crate::auth::session::cookie::CookieSessionsConfig {
98            session_ttl_secs: config.refresh_ttl_secs,
99            touch_interval_secs: config.touch_interval_secs,
100            max_sessions_per_user: config.max_per_user.max(1),
101            cookie_name: String::new(),
102            validate_fingerprint: false,
103            cookie: Default::default(),
104        };
105        let store = SessionStore::new(db, store_cfg);
106
107        Ok(Self {
108            inner: Arc::new(Inner {
109                store,
110                encoder,
111                decoder,
112                config,
113            }),
114        })
115    }
116
117    /// Returns a reference to the JWT encoder.
118    pub fn encoder(&self) -> &JwtEncoder {
119        &self.inner.encoder
120    }
121
122    /// Returns a reference to the JWT decoder.
123    pub fn decoder(&self) -> &JwtDecoder {
124        &self.inner.decoder
125    }
126
127    /// Returns a reference to the service configuration.
128    pub fn config(&self) -> &JwtSessionsConfig {
129        &self.inner.config
130    }
131
132    /// Returns a reference to the underlying session store.
133    #[cfg(any(test, feature = "test-helpers"))]
134    pub fn store(&self) -> &SessionStore {
135        &self.inner.store
136    }
137
138    /// Returns a reference to the underlying session store (crate-internal).
139    #[cfg(not(any(test, feature = "test-helpers")))]
140    pub(crate) fn store(&self) -> &SessionStore {
141        &self.inner.store
142    }
143
144    /// Creates a [`JwtLayer`](super::middleware::JwtLayer) backed by this service.
145    ///
146    /// The returned layer performs stateful validation: after verifying the JWT
147    /// signature and claims it hashes the `jti`, loads the session row, and
148    /// inserts the transport-agnostic [`Session`](crate::auth::session::Session)
149    /// into request extensions. Returns `401` when the session row is absent.
150    ///
151    /// # Example
152    ///
153    /// ```rust,ignore
154    /// let svc = JwtSessionService::new(db, config)?;
155    /// let app = Router::new()
156    ///     .route("/me", get(whoami).route_layer(svc.layer()));
157    /// ```
158    pub fn layer(&self) -> super::middleware::JwtLayer {
159        super::middleware::JwtLayer::from_service(self.clone())
160    }
161
162    /// Authenticate a user and issue a new [`TokenPair`].
163    ///
164    /// Creates a session row in the database. The access and refresh tokens
165    /// both carry the session token hex as the `jti` claim. The access token
166    /// audience is `"access"`; the refresh token audience is `"refresh"`.
167    ///
168    /// # Errors
169    ///
170    /// Returns an error if the session row cannot be created or the tokens
171    /// cannot be signed.
172    pub async fn authenticate(&self, user_id: &str, meta: &SessionMeta) -> Result<TokenPair> {
173        let (raw, token) = self.inner.store.create(meta, user_id, None).await?;
174        self.mint_pair(&raw.user_id, &token.expose())
175    }
176
177    /// Rotate a refresh token, issuing a new [`TokenPair`].
178    ///
179    /// Validates the provided `refresh_token` (signature, expiry, audience),
180    /// then rotates the stored session token hash and mints a fresh pair. The
181    /// old refresh token is immediately invalidated — a second call with the
182    /// same token returns `auth:session_not_found`.
183    ///
184    /// # Errors
185    ///
186    /// - `auth:aud_mismatch` — the token has the wrong audience (e.g. an access token was passed).
187    /// - `auth:session_not_found` — the session row does not exist or has expired.
188    /// - JWT validation errors (`jwt:*`) — expired, tampered, etc.
189    pub async fn rotate(&self, refresh_token: &str) -> Result<TokenPair> {
190        let claims: Claims = self.inner.decoder.decode(refresh_token)?;
191
192        if claims.aud.as_deref() != Some(AUD_REFRESH) {
193            return Err(Error::unauthorized("unauthorized").with_code("auth:aud_mismatch"));
194        }
195
196        let jti = claims.jti.as_deref().ok_or_else(|| {
197            Error::unauthorized("unauthorized").with_code("auth:session_not_found")
198        })?;
199
200        let old_token = SessionToken::from_raw(jti).ok_or_else(|| {
201            Error::unauthorized("unauthorized").with_code("auth:session_not_found")
202        })?;
203
204        let raw = self
205            .inner
206            .store
207            .read_by_token_hash(&old_token.hash())
208            .await?
209            .ok_or_else(|| {
210                Error::unauthorized("unauthorized").with_code("auth:session_not_found")
211            })?;
212
213        let new_token = SessionToken::generate();
214        self.inner
215            .store
216            .rotate_token_to(&raw.id, &new_token)
217            .await?;
218
219        self.mint_pair(&raw.user_id, &new_token.expose())
220    }
221
222    /// Revoke the session associated with an access token.
223    ///
224    /// Validates the `access_token` (signature, expiry, audience), then destroys
225    /// the session row. If the session is already gone (e.g. concurrent logout),
226    /// the call is a no-op and succeeds.
227    ///
228    /// # Errors
229    ///
230    /// - `auth:aud_mismatch` — a refresh token was passed instead of an access token.
231    /// - JWT validation errors (`jwt:*`) — expired, tampered, etc.
232    pub async fn logout(&self, access_token: &str) -> Result<()> {
233        let claims: Claims = self.inner.decoder.decode(access_token)?;
234
235        if claims.aud.as_deref() != Some(AUD_ACCESS) {
236            return Err(Error::unauthorized("unauthorized").with_code("auth:aud_mismatch"));
237        }
238
239        let jti = claims.jti.as_deref().ok_or_else(|| {
240            Error::unauthorized("unauthorized").with_code("auth:session_not_found")
241        })?;
242
243        let token = SessionToken::from_raw(jti).ok_or_else(|| {
244            Error::unauthorized("unauthorized").with_code("auth:session_not_found")
245        })?;
246
247        if let Some(raw) = self.inner.store.read_by_token_hash(&token.hash()).await? {
248            self.inner.store.destroy(&raw.id).await?;
249        }
250
251        Ok(())
252    }
253
254    /// List all active sessions for the given user.
255    ///
256    /// # Errors
257    ///
258    /// Returns an error if the database query fails.
259    pub async fn list(&self, user_id: &str) -> Result<Vec<Session>> {
260        let raws = self.inner.store.list_for_user(user_id).await?;
261        Ok(raws.into_iter().map(Session::from).collect())
262    }
263
264    /// Revoke a specific session by its ULID identifier.
265    ///
266    /// Looks up the session row by `id`, verifies that it belongs to `user_id`,
267    /// and destroys it. Returns `404 auth:session_not_found` if the session does
268    /// not exist or belongs to a different user.
269    ///
270    /// # Errors
271    ///
272    /// Returns `404 auth:session_not_found` on ownership mismatch, or an
273    /// internal error if the database operation fails.
274    pub async fn revoke(&self, user_id: &str, id: &str) -> Result<()> {
275        let row = self.inner.store.read(id).await?.ok_or_else(|| {
276            Error::not_found("session not found").with_code("auth:session_not_found")
277        })?;
278
279        if row.user_id != user_id {
280            return Err(Error::not_found("session not found").with_code("auth:session_not_found"));
281        }
282
283        self.inner.store.destroy(id).await
284    }
285
286    /// Revoke all sessions for the given user.
287    ///
288    /// # Errors
289    ///
290    /// Returns an error if the database delete fails.
291    pub async fn revoke_all(&self, user_id: &str) -> Result<()> {
292        self.inner.store.destroy_all_for_user(user_id).await
293    }
294
295    /// Revoke all sessions for the given user except the session with `keep_id`.
296    ///
297    /// Used to implement "log out other devices".
298    ///
299    /// # Errors
300    ///
301    /// Returns an error if the database delete fails.
302    pub async fn revoke_all_except(&self, user_id: &str, keep_id: &str) -> Result<()> {
303        self.inner.store.destroy_all_except(user_id, keep_id).await
304    }
305
306    /// Delete all expired sessions from the database.
307    ///
308    /// Returns the number of rows deleted. Schedule periodically (e.g. via a
309    /// cron job) to keep the table small.
310    ///
311    /// # Errors
312    ///
313    /// Returns an error if the database delete fails.
314    pub async fn cleanup_expired(&self) -> Result<u64> {
315        self.inner.store.cleanup_expired().await
316    }
317
318    /// Mint an access + refresh token pair for `user_id` with `jti` as the session token hex.
319    ///
320    /// TTLs are derived from `config.access_ttl_secs` and `config.refresh_ttl_secs`.
321    fn mint_pair(&self, user_id: &str, jti: &str) -> Result<TokenPair> {
322        let now = now_secs();
323        let access_exp = now + self.inner.config.access_ttl_secs;
324        let refresh_exp = now + self.inner.config.refresh_ttl_secs;
325
326        let access = Claims::new()
327            .with_sub(user_id)
328            .with_aud(AUD_ACCESS)
329            .with_jti(jti)
330            .with_exp(access_exp)
331            .with_iat_now();
332
333        let access = if let Some(ref iss) = self.inner.config.issuer {
334            access.with_iss(iss)
335        } else {
336            access
337        };
338
339        let refresh = Claims::new()
340            .with_sub(user_id)
341            .with_aud(AUD_REFRESH)
342            .with_jti(jti)
343            .with_exp(refresh_exp)
344            .with_iat_now();
345
346        let refresh = if let Some(ref iss) = self.inner.config.issuer {
347            refresh.with_iss(iss)
348        } else {
349            refresh
350        };
351
352        Ok(TokenPair {
353            access_token: self.inner.encoder.encode(&access)?,
354            refresh_token: self.inner.encoder.encode(&refresh)?,
355            access_expires_at: access_exp,
356            refresh_expires_at: refresh_exp,
357        })
358    }
359}