fraiseql_auth/
session_postgres.rs1use async_trait::async_trait;
3use sqlx::{Row, postgres::PgPool};
4
5use crate::{
6 error::{AuthError, Result},
7 session::{SessionData, SessionStore, TokenPair, generate_refresh_token, hash_token, unix_now},
8};
9
10pub struct PostgresSessionStore {
12 db: PgPool,
13 signing_key: Option<Vec<u8>>,
15}
16
17impl PostgresSessionStore {
18 #[must_use]
23 pub const fn new(db: PgPool) -> Self {
24 Self {
25 db,
26 signing_key: None,
27 }
28 }
29
30 #[must_use]
36 pub const fn with_rs256_key(db: PgPool, private_key_pem: Vec<u8>) -> Self {
37 Self {
38 db,
39 signing_key: Some(private_key_pem),
40 }
41 }
42
43 pub async fn init(&self) -> Result<()> {
50 sqlx::query(
51 r"
52 CREATE TABLE IF NOT EXISTS _system.sessions (
53 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
54 user_id TEXT NOT NULL,
55 refresh_token_hash TEXT NOT NULL UNIQUE,
56 issued_at BIGINT NOT NULL,
57 expires_at BIGINT NOT NULL,
58 created_at TIMESTAMPTZ DEFAULT NOW(),
59 revoked_at TIMESTAMPTZ
60 );
61
62 CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON _system.sessions(user_id);
63 CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON _system.sessions(expires_at);
64 CREATE INDEX IF NOT EXISTS idx_sessions_revoked_at ON _system.sessions(revoked_at);
65 ",
66 )
67 .execute(&self.db)
68 .await
69 .map_err(|e| AuthError::DatabaseError {
70 message: format!("Failed to initialize sessions table: {}", e),
71 })?;
72
73 Ok(())
74 }
75
76 fn generate_access_token(&self, user_id: &str, expires_in: u64) -> Result<String> {
81 let now = unix_now()?;
83
84 let exp = now + expires_in;
85
86 let mut claims = crate::Claims {
87 sub: user_id.to_string(),
88 iat: now,
89 exp,
90 nbf: None,
91 iss: "fraiseql".to_string(),
92 aud: vec!["fraiseql-api".to_string()],
93 extra: std::collections::HashMap::new(),
94 };
95
96 claims
98 .extra
99 .insert("jti".to_string(), serde_json::json!(uuid::Uuid::new_v4().to_string()));
100
101 if let Some(private_key) = &self.signing_key {
102 crate::jwt::generate_rs256_token(&claims, private_key)
103 } else {
104 use rand::Rng;
108 let key_bytes: [u8; 32] = rand::rng().random();
109 crate::jwt::generate_hs256_token(&claims, &key_bytes)
110 }
111 }
112}
113
114#[async_trait]
118impl SessionStore for PostgresSessionStore {
119 async fn create_session(&self, user_id: &str, expires_at: u64) -> Result<TokenPair> {
120 let refresh_token = generate_refresh_token();
121 let refresh_token_hash = hash_token(&refresh_token);
122
123 let now = unix_now()?;
125
126 sqlx::query(
127 r"
128 INSERT INTO _system.sessions
129 (user_id, refresh_token_hash, issued_at, expires_at)
130 VALUES ($1, $2, $3, $4)
131 ",
132 )
133 .bind(user_id)
134 .bind(&refresh_token_hash)
135 .bind(now.cast_signed())
136 .bind(expires_at.cast_signed())
137 .execute(&self.db)
138 .await
139 .map_err(|e| {
140 if e.to_string().contains("duplicate key") {
141 AuthError::SessionError {
142 message: "Refresh token already exists".to_string(),
143 }
144 } else {
145 AuthError::DatabaseError {
146 message: format!("Failed to create session: {}", e),
147 }
148 }
149 })?;
150
151 let expires_in = expires_at.saturating_sub(now);
152 let access_token = self.generate_access_token(user_id, expires_in)?;
153
154 Ok(TokenPair {
155 access_token,
156 refresh_token,
157 expires_in,
158 })
159 }
160
161 async fn get_session(&self, refresh_token_hash: &str) -> Result<SessionData> {
162 let row = sqlx::query(
163 r"
164 SELECT user_id, issued_at, expires_at, refresh_token_hash
165 FROM _system.sessions
166 WHERE refresh_token_hash = $1 AND revoked_at IS NULL
167 ",
168 )
169 .bind(refresh_token_hash)
170 .fetch_optional(&self.db)
171 .await
172 .map_err(|e| AuthError::DatabaseError {
173 message: format!("Failed to get session: {}", e),
174 })?
175 .ok_or(AuthError::TokenNotFound)?;
176
177 let user_id: String = row.get("user_id");
178 let issued_at: i64 = row.get("issued_at");
179 let expires_at: i64 = row.get("expires_at");
180 let refresh_token_hash: String = row.get("refresh_token_hash");
181
182 Ok(SessionData {
183 user_id,
184 issued_at: issued_at.cast_unsigned(),
185 expires_at: expires_at.cast_unsigned(),
186 refresh_token_hash,
187 })
188 }
189
190 async fn revoke_session(&self, refresh_token_hash: &str) -> Result<()> {
191 let result = sqlx::query(
192 r"
193 UPDATE _system.sessions
194 SET revoked_at = NOW()
195 WHERE refresh_token_hash = $1 AND revoked_at IS NULL
196 ",
197 )
198 .bind(refresh_token_hash)
199 .execute(&self.db)
200 .await
201 .map_err(|e| AuthError::DatabaseError {
202 message: format!("Failed to revoke session: {}", e),
203 })?;
204
205 if result.rows_affected() == 0 {
206 return Err(AuthError::SessionError {
207 message: "Session not found or already revoked".to_string(),
208 });
209 }
210
211 Ok(())
212 }
213
214 async fn revoke_all_sessions(&self, user_id: &str) -> Result<()> {
215 sqlx::query(
216 r"
217 UPDATE _system.sessions
218 SET revoked_at = NOW()
219 WHERE user_id = $1 AND revoked_at IS NULL
220 ",
221 )
222 .bind(user_id)
223 .execute(&self.db)
224 .await
225 .map_err(|e| AuthError::DatabaseError {
226 message: format!("Failed to revoke all sessions: {}", e),
227 })?;
228
229 Ok(())
230 }
231}