Skip to main content

fraiseql_auth/
session_postgres.rs

1//! PostgreSQL-backed [`SessionStore`] implementation.
2use 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
10/// PostgreSQL-backed session store
11pub struct PostgresSessionStore {
12    db:          PgPool,
13    /// Optional RSA private key for JWT signing (None falls back to HMAC)
14    signing_key: Option<Vec<u8>>,
15}
16
17impl PostgresSessionStore {
18    /// Create a new PostgreSQL session store
19    ///
20    /// # Errors
21    /// Returns error if database connection fails
22    #[must_use]
23    pub const fn new(db: PgPool) -> Self {
24        Self {
25            db,
26            signing_key: None,
27        }
28    }
29
30    /// Create a new PostgreSQL session store with RS256 JWT signing
31    ///
32    /// # Arguments
33    /// * `db` - PostgreSQL connection pool
34    /// * `private_key_pem` - RSA private key in PEM format
35    #[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    /// Initialize the sessions table
44    ///
45    /// This should be called once during server startup to ensure the table exists.
46    ///
47    /// # Errors
48    /// Returns error if table creation fails
49    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    /// Generate a JWT access token with RS256 or HMAC signing
77    ///
78    /// Uses RS256 if a signing key is configured, otherwise falls back to HMAC with a
79    /// 256-bit randomly-generated key (via `OsRng`) so the secret has full entropy.
80    fn generate_access_token(&self, user_id: &str, expires_in: u64) -> Result<String> {
81        // SECURITY: Propagate clock errors; unwrap_or_default would produce iat=0.
82        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        // Add JTI (JWT ID) for uniqueness
97        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            // SECURITY: Generate a 256-bit random HMAC key per token so the secret
105            // has full entropy regardless of user_id length or content.
106            // Using OsRng (backed by the OS CSPRNG) ensures cryptographic quality.
107            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// Reason: SessionStore is defined with #[async_trait]; all implementations must match
115// its transformed method signatures to satisfy the trait contract
116// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
117#[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        // SECURITY: Propagate clock errors; unwrap_or_default would produce issued_at=0.
124        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}