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}