1use std::sync::Arc;
9
10use anyhow::{Context, Result};
11use sqlx::{PgPool, Row};
12
13use super::types::{PasskeyCred, Session, User};
14use super::{SessionStore, UserStore};
15
16#[derive(Clone)]
19pub struct PostgresUserStore {
20 pool: PgPool,
21}
22
23impl PostgresUserStore {
24 pub fn new(pool: PgPool) -> Self {
25 Self { pool }
26 }
27
28 pub fn into_dyn(self) -> Arc<dyn UserStore> {
30 Arc::new(self)
31 }
32}
33
34#[async_trait::async_trait]
35impl UserStore for PostgresUserStore {
36 async fn create_user(&self, user: &User) -> Result<()> {
37 sqlx::query(
38 "INSERT INTO auth.users
39 (id, email, email_verified, display_name, password_hash, created_at)
40 VALUES ($1, $2, $3, $4, NULL, $5)",
41 )
42 .bind(&user.id)
43 .bind(&user.email)
44 .bind(user.email_verified)
45 .bind(&user.display_name)
46 .bind(user.created_at)
47 .execute(&self.pool)
48 .await
49 .context("auth.users insert")?;
50 Ok(())
51 }
52
53 async fn get_user_by_id(&self, id: &str) -> Result<Option<User>> {
54 let row = sqlx::query(
55 "SELECT id, email, email_verified, display_name, created_at
56 FROM auth.users WHERE id = $1",
57 )
58 .bind(id)
59 .fetch_optional(&self.pool)
60 .await
61 .context("auth.users select by id")?;
62 Ok(row.map(map_user_row_pg))
63 }
64
65 async fn get_user_by_email(&self, email: &str) -> Result<Option<User>> {
66 let row = sqlx::query(
67 "SELECT id, email, email_verified, display_name, created_at
68 FROM auth.users WHERE email = $1",
69 )
70 .bind(email)
71 .fetch_optional(&self.pool)
72 .await
73 .context("auth.users select by email")?;
74 Ok(row.map(map_user_row_pg))
75 }
76
77 async fn update_user(&self, user: &User) -> Result<()> {
78 sqlx::query(
79 "UPDATE auth.users
80 SET email = $2,
81 email_verified = $3,
82 display_name = $4
83 WHERE id = $1",
84 )
85 .bind(&user.id)
86 .bind(&user.email)
87 .bind(user.email_verified)
88 .bind(&user.display_name)
89 .execute(&self.pool)
90 .await
91 .context("auth.users update")?;
92 Ok(())
93 }
94
95 async fn set_password_hash(&self, user_id: &str, hash: &str) -> Result<()> {
96 sqlx::query("UPDATE auth.users SET password_hash = $2 WHERE id = $1")
97 .bind(user_id)
98 .bind(hash)
99 .execute(&self.pool)
100 .await
101 .context("auth.users set password_hash")?;
102 Ok(())
103 }
104
105 async fn get_password_hash(&self, user_id: &str) -> Result<Option<String>> {
106 let row: Option<(Option<String>,)> =
107 sqlx::query_as("SELECT password_hash FROM auth.users WHERE id = $1")
108 .bind(user_id)
109 .fetch_optional(&self.pool)
110 .await
111 .context("auth.users select password_hash")?;
112 Ok(row.and_then(|r| r.0))
113 }
114
115 async fn list_passkeys(&self, user_id: &str) -> Result<Vec<PasskeyCred>> {
116 let rows = sqlx::query(
117 "SELECT credential_id, public_key, sign_count, transports, created_at
118 FROM auth.passkeys WHERE user_id = $1
119 ORDER BY created_at",
120 )
121 .bind(user_id)
122 .fetch_all(&self.pool)
123 .await
124 .context("auth.passkeys list")?;
125 Ok(rows.into_iter().map(map_passkey_row_pg).collect())
126 }
127
128 async fn add_passkey(&self, user_id: &str, cred: &PasskeyCred) -> Result<()> {
129 sqlx::query(
130 "INSERT INTO auth.passkeys
131 (credential_id, user_id, public_key, sign_count, transports, created_at)
132 VALUES ($1, $2, $3, $4, $5, $6)",
133 )
134 .bind(&cred.credential_id)
135 .bind(user_id)
136 .bind(&cred.public_key)
137 .bind(cred.sign_count as i32)
138 .bind(cred.transports.join(","))
139 .bind(cred.created_at)
140 .execute(&self.pool)
141 .await
142 .context("auth.passkeys insert")?;
143 Ok(())
144 }
145
146 async fn remove_passkey(&self, credential_id: &[u8]) -> Result<bool> {
147 let res = sqlx::query("DELETE FROM auth.passkeys WHERE credential_id = $1")
148 .bind(credential_id)
149 .execute(&self.pool)
150 .await
151 .context("auth.passkeys delete")?;
152 Ok(res.rows_affected() > 0)
153 }
154
155 async fn link_upstream(&self, user_id: &str, provider: &str, subject: &str) -> Result<()> {
156 sqlx::query(
157 "INSERT INTO auth.user_upstream (provider, subject, user_id)
158 VALUES ($1, $2, $3)
159 ON CONFLICT (provider, subject) DO UPDATE SET user_id = EXCLUDED.user_id",
160 )
161 .bind(provider)
162 .bind(subject)
163 .bind(user_id)
164 .execute(&self.pool)
165 .await
166 .context("auth.user_upstream upsert")?;
167 Ok(())
168 }
169
170 async fn get_user_by_upstream(&self, provider: &str, subject: &str) -> Result<Option<User>> {
171 let row = sqlx::query(
172 "SELECT u.id, u.email, u.email_verified, u.display_name, u.created_at
173 FROM auth.users u
174 JOIN auth.user_upstream l ON l.user_id = u.id
175 WHERE l.provider = $1 AND l.subject = $2",
176 )
177 .bind(provider)
178 .bind(subject)
179 .fetch_optional(&self.pool)
180 .await
181 .context("auth.user_upstream lookup")?;
182 Ok(row.map(map_user_row_pg))
183 }
184
185 async fn list_users(&self, limit: i64, offset: i64, search: Option<&str>) -> Result<Vec<User>> {
186 let lim = limit.clamp(1, 500);
189 let off = offset.max(0);
190 let rows = if let Some(needle) = search {
191 let pat = format!("%{}%", needle.to_lowercase());
192 sqlx::query(
193 "SELECT id, email, email_verified, display_name, created_at
194 FROM auth.users
195 WHERE LOWER(COALESCE(email, '')) LIKE $1
196 OR LOWER(COALESCE(display_name, '')) LIKE $1
197 ORDER BY created_at DESC
198 LIMIT $2 OFFSET $3",
199 )
200 .bind(pat)
201 .bind(lim)
202 .bind(off)
203 .fetch_all(&self.pool)
204 .await
205 .context("auth.users list (search)")?
206 } else {
207 sqlx::query(
208 "SELECT id, email, email_verified, display_name, created_at
209 FROM auth.users
210 ORDER BY created_at DESC
211 LIMIT $1 OFFSET $2",
212 )
213 .bind(lim)
214 .bind(off)
215 .fetch_all(&self.pool)
216 .await
217 .context("auth.users list")?
218 };
219 Ok(rows.into_iter().map(map_user_row_pg).collect())
220 }
221
222 async fn count_users(&self, search: Option<&str>) -> Result<i64> {
223 let row: (i64,) = if let Some(needle) = search {
224 let pat = format!("%{}%", needle.to_lowercase());
225 sqlx::query_as(
226 "SELECT COUNT(*) FROM auth.users
227 WHERE LOWER(COALESCE(email, '')) LIKE $1
228 OR LOWER(COALESCE(display_name, '')) LIKE $1",
229 )
230 .bind(pat)
231 .fetch_one(&self.pool)
232 .await
233 .context("auth.users count (search)")?
234 } else {
235 sqlx::query_as("SELECT COUNT(*) FROM auth.users")
236 .fetch_one(&self.pool)
237 .await
238 .context("auth.users count")?
239 };
240 Ok(row.0)
241 }
242
243 async fn delete_user(&self, id: &str) -> Result<bool> {
244 let res = sqlx::query("DELETE FROM auth.users WHERE id = $1")
245 .bind(id)
246 .execute(&self.pool)
247 .await
248 .context("auth.users delete")?;
249 Ok(res.rows_affected() > 0)
250 }
251
252 async fn list_upstream_for_user(&self, user_id: &str) -> Result<Vec<(String, String)>> {
253 let rows = sqlx::query(
254 "SELECT provider, subject FROM auth.user_upstream
255 WHERE user_id = $1 ORDER BY provider, subject",
256 )
257 .bind(user_id)
258 .fetch_all(&self.pool)
259 .await
260 .context("auth.user_upstream list")?;
261 Ok(rows
262 .into_iter()
263 .map(|r| {
264 (
265 r.get::<String, _>("provider"),
266 r.get::<String, _>("subject"),
267 )
268 })
269 .collect())
270 }
271}
272
273#[derive(Clone)]
277pub struct PostgresSessionStore {
278 pool: PgPool,
279}
280
281impl PostgresSessionStore {
282 pub fn new(pool: PgPool) -> Self {
283 Self { pool }
284 }
285
286 pub fn into_dyn(self) -> Arc<dyn SessionStore> {
287 Arc::new(self)
288 }
289}
290
291#[async_trait::async_trait]
292impl SessionStore for PostgresSessionStore {
293 async fn create(&self, session: &Session) -> Result<()> {
294 sqlx::query(
295 "INSERT INTO auth.sessions
296 (id, user_id, csrf_token, created_at, expires_at, ip_hash, user_agent_hash)
297 VALUES ($1, $2, $3, $4, $5, $6, $7)",
298 )
299 .bind(&session.id)
300 .bind(&session.user_id)
301 .bind(&session.csrf_token)
302 .bind(session.created_at)
303 .bind(session.expires_at)
304 .bind(&session.ip_hash)
305 .bind(&session.user_agent_hash)
306 .execute(&self.pool)
307 .await
308 .context("auth.sessions insert")?;
309 Ok(())
310 }
311
312 async fn get(&self, id: &str) -> Result<Option<Session>> {
313 let row = sqlx::query(
314 "SELECT id, user_id, csrf_token, created_at, expires_at, ip_hash, user_agent_hash
315 FROM auth.sessions WHERE id = $1",
316 )
317 .bind(id)
318 .fetch_optional(&self.pool)
319 .await
320 .context("auth.sessions select")?;
321 Ok(row.map(map_session_row_pg))
322 }
323
324 async fn delete(&self, id: &str) -> Result<bool> {
325 let res = sqlx::query("DELETE FROM auth.sessions WHERE id = $1")
326 .bind(id)
327 .execute(&self.pool)
328 .await
329 .context("auth.sessions delete")?;
330 Ok(res.rows_affected() > 0)
331 }
332
333 async fn list_for_user(&self, user_id: &str) -> Result<Vec<Session>> {
334 let rows = sqlx::query(
335 "SELECT id, user_id, csrf_token, created_at, expires_at, ip_hash, user_agent_hash
336 FROM auth.sessions WHERE user_id = $1 ORDER BY created_at DESC",
337 )
338 .bind(user_id)
339 .fetch_all(&self.pool)
340 .await
341 .context("auth.sessions list_for_user")?;
342 Ok(rows.into_iter().map(map_session_row_pg).collect())
343 }
344
345 async fn delete_for_user(&self, user_id: &str) -> Result<u64> {
346 let res = sqlx::query("DELETE FROM auth.sessions WHERE user_id = $1")
347 .bind(user_id)
348 .execute(&self.pool)
349 .await
350 .context("auth.sessions delete_for_user")?;
351 Ok(res.rows_affected())
352 }
353
354 async fn purge_expired(&self, now: f64) -> Result<u64> {
355 let res = sqlx::query("DELETE FROM auth.sessions WHERE expires_at <= $1")
356 .bind(now)
357 .execute(&self.pool)
358 .await
359 .context("auth.sessions purge_expired")?;
360 Ok(res.rows_affected())
361 }
362
363 async fn list_all(
364 &self,
365 limit: i64,
366 offset: i64,
367 user_filter: Option<&str>,
368 ) -> Result<Vec<Session>> {
369 let lim = limit.clamp(1, 500);
370 let off = offset.max(0);
371 let rows = if let Some(uid) = user_filter {
372 sqlx::query(
373 "SELECT id, user_id, csrf_token, created_at, expires_at, ip_hash, user_agent_hash
374 FROM auth.sessions WHERE user_id = $1
375 ORDER BY created_at DESC
376 LIMIT $2 OFFSET $3",
377 )
378 .bind(uid)
379 .bind(lim)
380 .bind(off)
381 .fetch_all(&self.pool)
382 .await
383 .context("auth.sessions list_all (user filter)")?
384 } else {
385 sqlx::query(
386 "SELECT id, user_id, csrf_token, created_at, expires_at, ip_hash, user_agent_hash
387 FROM auth.sessions
388 ORDER BY created_at DESC
389 LIMIT $1 OFFSET $2",
390 )
391 .bind(lim)
392 .bind(off)
393 .fetch_all(&self.pool)
394 .await
395 .context("auth.sessions list_all")?
396 };
397 Ok(rows.into_iter().map(map_session_row_pg).collect())
398 }
399
400 async fn count_all(&self, user_filter: Option<&str>) -> Result<i64> {
401 let row: (i64,) = if let Some(uid) = user_filter {
402 sqlx::query_as("SELECT COUNT(*) FROM auth.sessions WHERE user_id = $1")
403 .bind(uid)
404 .fetch_one(&self.pool)
405 .await
406 .context("auth.sessions count_all (user filter)")?
407 } else {
408 sqlx::query_as("SELECT COUNT(*) FROM auth.sessions")
409 .fetch_one(&self.pool)
410 .await
411 .context("auth.sessions count_all")?
412 };
413 Ok(row.0)
414 }
415}
416
417fn map_user_row_pg(row: sqlx::postgres::PgRow) -> User {
418 User {
419 id: row.get("id"),
420 email: row.get("email"),
421 email_verified: row.get("email_verified"),
422 display_name: row.get("display_name"),
423 created_at: row.get("created_at"),
424 }
425}
426
427fn map_session_row_pg(row: sqlx::postgres::PgRow) -> Session {
428 Session {
429 id: row.get("id"),
430 user_id: row.get("user_id"),
431 csrf_token: row.get("csrf_token"),
432 created_at: row.get("created_at"),
433 expires_at: row.get("expires_at"),
434 ip_hash: row.get("ip_hash"),
435 user_agent_hash: row.get("user_agent_hash"),
436 }
437}
438
439fn map_passkey_row_pg(row: sqlx::postgres::PgRow) -> PasskeyCred {
440 let transports: String = row.get("transports");
441 let sign_count: i32 = row.get("sign_count");
442 PasskeyCred {
443 credential_id: row.get("credential_id"),
444 public_key: row.get("public_key"),
445 sign_count: sign_count.max(0) as u32,
446 transports: if transports.is_empty() {
447 Vec::new()
448 } else {
449 transports.split(',').map(|s| s.to_string()).collect()
450 },
451 created_at: row.get("created_at"),
452 }
453}