Skip to main content

soma_auth/
sqlite.rs

1use std::fmt::Write as _;
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicUsize, Ordering};
4use std::sync::{Arc, Mutex};
5
6use rusqlite::types::Value;
7use rusqlite::{Connection, OptionalExtension, params};
8use sha2::{Digest, Sha256};
9use tracing::warn;
10
11use crate::at_rest::{TokenEncryptionKey, maybe_decrypt_bound, maybe_encrypt_bound};
12use crate::error::AuthError;
13use crate::types::{
14    AllowedUserRow, AuthorizationCodeRow, AuthorizationRequestRow, RefreshTokenRow,
15    RegisteredClient,
16};
17
18#[path = "sqlite_assertions.rs"]
19mod sqlite_assertions;
20#[path = "sqlite_browser_sessions.rs"]
21mod sqlite_browser_sessions;
22#[path = "sqlite_migrations.rs"]
23mod sqlite_migrations;
24
25use sqlite_migrations::{add_column_if_missing, run_migrations};
26#[path = "sqlite_rows.rs"]
27mod sqlite_rows;
28#[path = "sqlite_upstream_oauth.rs"]
29mod sqlite_upstream_oauth;
30use sqlite_rows::{row_to_allowed_user, row_to_authorization_code, row_to_authorization_request};
31
32/// Schema version for the `PRAGMA user_version` migration guard.
33/// Increment this whenever a migration step is added to `run_migrations`.
34const SCHEMA_VERSION: i64 = 5;
35
36use crate::util::{
37    ensure_restrictive_permissions, fingerprint, now_unix, set_restrictive_permissions,
38};
39
40const SQLITE_BUSY_TIMEOUT_MS: u64 = 5_000;
41const SQLITE_POOL_SIZE: usize = 4;
42
43#[derive(Clone)]
44pub struct SqliteStore {
45    conns: Arc<Vec<Mutex<Connection>>>,
46    next_conn: Arc<AtomicUsize>,
47    path: Arc<PathBuf>,
48    /// Optional at-rest encryption key for upstream provider refresh tokens.
49    enc_key: Option<Arc<TokenEncryptionKey>>,
50}
51
52impl std::fmt::Debug for SqliteStore {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        f.debug_struct("SqliteStore")
55            .field("path", &self.path)
56            .field("enc_key", &self.enc_key.as_ref().map(|_| "<redacted>"))
57            .finish_non_exhaustive()
58    }
59}
60
61impl SqliteStore {
62    pub async fn open(path: PathBuf) -> Result<Self, AuthError> {
63        Self::open_with_key(path, None).await
64    }
65
66    pub async fn open_with_key(
67        path: PathBuf,
68        enc_key: Option<TokenEncryptionKey>,
69    ) -> Result<Self, AuthError> {
70        let path_for_open = path.clone();
71        let conns = tokio::task::spawn_blocking(move || {
72            open_connections(path_for_open.as_path(), SQLITE_POOL_SIZE)
73        })
74        .await;
75        let store = match conns {
76            Ok(result) => result,
77            Err(error) => Err(AuthError::Storage(format!(
78                "sqlite open task failed: {error}"
79            ))),
80        }
81        .map(|conns| Self {
82            conns: Arc::new(conns.into_iter().map(Mutex::new).collect()),
83            next_conn: Arc::new(AtomicUsize::new(0)),
84            path: Arc::new(path),
85            enc_key: enc_key.map(Arc::new),
86        })?;
87
88        store.cleanup_expired().await?;
89        Ok(store)
90    }
91
92    pub async fn pragma(&self, name: &str) -> Result<String, AuthError> {
93        let pragma = match name {
94            "journal_mode" | "busy_timeout" | "foreign_keys" => name.to_string(),
95            other => {
96                return Err(AuthError::Config(format!(
97                    "unsupported pragma query `{other}`"
98                )));
99            }
100        };
101
102        self.with_conn(move |conn| {
103            conn.query_row(&format!("PRAGMA {pragma};"), [], |row| {
104                row.get::<_, Value>(0)
105            })
106            .map(|value| match value {
107                Value::Text(text) => text,
108                Value::Integer(int) => int.to_string(),
109                other => format!("{other:?}"),
110            })
111            .map_err(sqlite_error)
112        })
113        .await
114    }
115
116    pub async fn register_client(&self, client: RegisteredClient) -> Result<(), AuthError> {
117        self.with_conn(move |conn| {
118            let redirect_uris = serde_json::to_string(&client.redirect_uris)
119                .map_err(|error| AuthError::Storage(format!("serialize redirect_uris: {error}")))?;
120            let jwks = client
121                .jwks
122                .as_ref()
123                .map(serde_json::to_string)
124                .transpose()
125                .map_err(|error| AuthError::Storage(format!("serialize client jwks: {error}")))?;
126            conn.execute(
127                "INSERT INTO registered_clients (
128                    client_id, redirect_uris, created_at, token_endpoint_auth_method, jwks
129                 ) VALUES (?1, ?2, ?3, ?4, ?5)
130                 ON CONFLICT(client_id) DO UPDATE SET
131                    redirect_uris = excluded.redirect_uris,
132                    created_at = excluded.created_at,
133                    token_endpoint_auth_method = excluded.token_endpoint_auth_method,
134                    jwks = excluded.jwks",
135                params![
136                    client.client_id,
137                    redirect_uris,
138                    client.created_at,
139                    client.token_endpoint_auth_method,
140                    jwks,
141                ],
142            )
143            .map_err(sqlite_error)?;
144            Ok(())
145        })
146        .await
147    }
148
149    pub async fn find_client(
150        &self,
151        client_id: &str,
152    ) -> Result<Option<RegisteredClient>, AuthError> {
153        let client_id = client_id.to_string();
154        self.with_conn(move |conn| {
155            conn.query_row(
156                "SELECT client_id, redirect_uris, created_at, token_endpoint_auth_method, jwks
157                 FROM registered_clients
158                 WHERE client_id = ?1",
159                params![client_id],
160                |row| {
161                    let redirect_uris: String = row.get(1)?;
162                    let redirect_uris = serde_json::from_str(&redirect_uris).map_err(|error| {
163                        rusqlite::Error::FromSqlConversionFailure(
164                            1,
165                            rusqlite::types::Type::Text,
166                            Box::new(error),
167                        )
168                    })?;
169                    let jwks: Option<String> = row.get(4)?;
170                    let jwks = jwks
171                        .map(|value| serde_json::from_str(&value))
172                        .transpose()
173                        .map_err(|error| {
174                            rusqlite::Error::FromSqlConversionFailure(
175                                4,
176                                rusqlite::types::Type::Text,
177                                Box::new(error),
178                            )
179                        })?;
180                    Ok(RegisteredClient {
181                        client_id: row.get(0)?,
182                        redirect_uris,
183                        created_at: row.get(2)?,
184                        token_endpoint_auth_method: row.get(3)?,
185                        jwks,
186                    })
187                },
188            )
189            .optional()
190            .map_err(sqlite_error)
191        })
192        .await
193    }
194
195    pub async fn insert_authorization_request(
196        &self,
197        request: AuthorizationRequestRow,
198    ) -> Result<(), AuthError> {
199        self.with_conn(move |conn| {
200            conn.execute(
201                "INSERT INTO authorization_requests (
202                    state, client_id, redirect_uri, client_state, resource, scope, provider_code_verifier,
203                    code_challenge, code_challenge_method, created_at, expires_at, provider,
204                    token_endpoint_auth_method
205                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
206                params![
207                    request.state,
208                    request.client_id,
209                    request.redirect_uri,
210                    request.client_state,
211                    request.resource,
212                    request.scope,
213                    request.provider_code_verifier,
214                    request.code_challenge,
215                    request.code_challenge_method,
216                    request.created_at,
217                    request.expires_at,
218                    request.provider,
219                    request.token_endpoint_auth_method,
220                ],
221            )
222            .map_err(sqlite_error)?;
223            Ok(())
224        })
225        .await
226    }
227
228    pub async fn take_authorization_request(
229        &self,
230        state: &str,
231    ) -> Result<AuthorizationRequestRow, AuthError> {
232        let state = state.to_string();
233        let now = now_unix();
234        self.with_conn(move |conn| {
235            conn.query_row(
236                "DELETE FROM authorization_requests
237                 WHERE state = ?1
238                   AND expires_at > ?2
239                 RETURNING state, client_id, redirect_uri, client_state, scope, provider_code_verifier,
240                           code_challenge, code_challenge_method, created_at, expires_at, resource, provider,
241                           token_endpoint_auth_method",
242                params![state, now],
243                row_to_authorization_request,
244            )
245            .map_err(|error| match error {
246                rusqlite::Error::QueryReturnedNoRows => AuthError::InvalidGrant(
247                    "authorization state is missing, expired, or already used".to_string(),
248                ),
249                other => sqlite_error(other),
250            })
251        })
252        .await
253    }
254
255    pub async fn insert_auth_code(&self, code: AuthorizationCodeRow) -> Result<(), AuthError> {
256        self.with_conn(move |conn| {
257            conn.execute(
258                "INSERT INTO authorization_codes (
259                    code, client_id, subject, redirect_uri, resource, scope,
260                    code_challenge, code_challenge_method, provider_refresh_token,
261                    created_at, expires_at, provider, token_endpoint_auth_method
262                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
263                params![
264                    code.code,
265                    code.client_id,
266                    code.subject,
267                    code.redirect_uri,
268                    code.resource,
269                    code.scope,
270                    code.code_challenge,
271                    code.code_challenge_method,
272                    code.provider_refresh_token,
273                    code.created_at,
274                    code.expires_at,
275                    code.provider,
276                    code.token_endpoint_auth_method,
277                ],
278            )
279            .map_err(sqlite_error)?;
280            Ok(())
281        })
282        .await
283    }
284
285    pub async fn redeem_auth_code(&self, code: &str) -> Result<AuthorizationCodeRow, AuthError> {
286        let code = code.to_string();
287        let now = now_unix();
288        self.with_conn(move |conn| {
289            conn.query_row(
290                "DELETE FROM authorization_codes
291                 WHERE code = ?1
292                   AND expires_at > ?2
293                 RETURNING code, client_id, subject, redirect_uri, scope,
294                           code_challenge, code_challenge_method, provider_refresh_token,
295                           created_at, expires_at, resource, provider,
296                           token_endpoint_auth_method",
297                params![code, now],
298                row_to_authorization_code,
299            )
300            .map_err(|error| match error {
301                rusqlite::Error::QueryReturnedNoRows => AuthError::InvalidGrant(
302                    "authorization code is missing, expired, or already redeemed".to_string(),
303                ),
304                other => sqlite_error(other),
305            })
306        })
307        .await
308    }
309
310    /// Insert a new refresh token row, storing a SHA-256 hash of the raw token
311    /// as the primary key.  The plaintext token is **never** persisted; only the
312    /// caller-returned value contains it.  If an encryption key is configured,
313    /// `provider_refresh_token` is encrypted at rest before storage.
314    ///
315    /// Use [`Self::rotate_refresh_token`] instead of calling this twice when replacing
316    /// an existing token — that method performs the swap atomically.
317    pub async fn upsert_refresh_token(&self, token: RefreshTokenRow) -> Result<(), AuthError> {
318        let hash = hash_token(&token.refresh_token);
319        let encrypted_provider_rt = token
320            .provider_refresh_token
321            .as_deref()
322            .map(|raw| maybe_encrypt_bound(self.enc_key.as_deref(), raw, &refresh_token_aad(&hash)))
323            .transpose()?;
324        self.with_conn(move |conn| {
325            conn.execute(
326                "INSERT INTO refresh_tokens (
327                    refresh_token_hash, client_id, subject, resource, scope,
328                    provider_refresh_token, created_at, expires_at, provider,
329                    token_endpoint_auth_method
330                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
331                 ON CONFLICT(refresh_token_hash) DO UPDATE SET
332                    client_id = excluded.client_id,
333                    subject = excluded.subject,
334                    resource = excluded.resource,
335                    scope = excluded.scope,
336                    provider_refresh_token = excluded.provider_refresh_token,
337                    created_at = excluded.created_at,
338                    expires_at = excluded.expires_at,
339                    provider = excluded.provider,
340                    token_endpoint_auth_method = excluded.token_endpoint_auth_method",
341                params![
342                    hash,
343                    token.client_id,
344                    token.subject,
345                    token.resource,
346                    token.scope,
347                    encrypted_provider_rt,
348                    token.created_at,
349                    token.expires_at,
350                    token.provider,
351                    token.token_endpoint_auth_method,
352                ],
353            )
354            .map_err(sqlite_error)?;
355            Ok(())
356        })
357        .await
358    }
359
360    /// Atomically replace an existing refresh token with a new one in a single
361    /// SQLite transaction.  The old token is deleted and the new token is
362    /// inserted; if the old token is not found or has expired the operation
363    /// fails without inserting the new row (replay-safe).
364    ///
365    /// Both the DELETE and the INSERT are wrapped in an explicit `BEGIN` /
366    /// `COMMIT` so a crash between the two statements cannot leave the database
367    /// without a valid refresh token.
368    ///
369    /// Returns the newly issued `RefreshTokenRow` (with `refresh_token` set to
370    /// the new plaintext value) on success.
371    pub async fn rotate_refresh_token(
372        &self,
373        old_token: &str,
374        new_token: RefreshTokenRow,
375    ) -> Result<Option<RefreshTokenRow>, AuthError> {
376        let old_hash = hash_token(old_token);
377        let new_hash = hash_token(&new_token.refresh_token);
378        let now = now_unix();
379        let encrypted_provider_rt = new_token
380            .provider_refresh_token
381            .as_deref()
382            .map(|raw| {
383                maybe_encrypt_bound(self.enc_key.as_deref(), raw, &refresh_token_aad(&new_hash))
384            })
385            .transpose()?;
386        self.with_conn(move |conn| {
387            conn.execute_batch("BEGIN").map_err(sqlite_error)?;
388
389            let delete_result = conn
390                .execute(
391                    "DELETE FROM refresh_tokens
392                     WHERE refresh_token_hash = ?1
393                       AND expires_at > ?2",
394                    params![old_hash, now],
395                )
396                .map_err(sqlite_error);
397
398            let deleted = match delete_result {
399                Ok(n) => n,
400                Err(e) => {
401                    drop(conn.execute_batch("ROLLBACK"));
402                    return Err(e);
403                }
404            };
405
406            if deleted == 0 {
407                // Old token not found or already expired — rollback and reject.
408                drop(conn.execute_batch("ROLLBACK"));
409                return Ok(None);
410            }
411
412            let insert_result = conn
413                .execute(
414                    "INSERT INTO refresh_tokens (
415                    refresh_token_hash, client_id, subject, resource, scope,
416                    provider_refresh_token, created_at, expires_at, provider,
417                    token_endpoint_auth_method
418                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
419                    params![
420                        new_hash,
421                        new_token.client_id,
422                        new_token.subject,
423                        new_token.resource,
424                        new_token.scope,
425                        encrypted_provider_rt,
426                        new_token.created_at,
427                        new_token.expires_at,
428                        new_token.provider,
429                        new_token.token_endpoint_auth_method,
430                    ],
431                )
432                .map_err(sqlite_error);
433
434            match insert_result {
435                Ok(_) => {
436                    conn.execute_batch("COMMIT").map_err(sqlite_error)?;
437                    Ok(Some(new_token))
438                }
439                Err(e) => {
440                    drop(conn.execute_batch("ROLLBACK"));
441                    Err(e)
442                }
443            }
444        })
445        .await
446    }
447
448    pub async fn find_refresh_token(
449        &self,
450        refresh_token: &str,
451    ) -> Result<Option<RefreshTokenRow>, AuthError> {
452        let hash = hash_token(refresh_token);
453        // Keep the plaintext value in memory so the caller receives a row with
454        // `refresh_token` populated (the DB never stores it).
455        let plaintext = refresh_token.to_string();
456        let now = now_unix();
457        let enc_key = self.enc_key.clone();
458        self.with_conn(move |conn| {
459            let row = conn
460                .query_row(
461                    "SELECT client_id, subject, scope,
462                        provider_refresh_token, created_at, expires_at, resource, provider,
463                        token_endpoint_auth_method
464                 FROM refresh_tokens
465                 WHERE refresh_token_hash = ?1
466                   AND expires_at > ?2",
467                    params![hash, now],
468                    |row| {
469                        Ok(RefreshTokenRow {
470                            refresh_token: plaintext.clone(),
471                            client_id: row.get(0)?,
472                            subject: row.get(1)?,
473                            scope: row.get(2)?,
474                            provider_refresh_token: row.get(3)?,
475                            created_at: row.get(4)?,
476                            expires_at: row.get(5)?,
477                            resource: row.get(6).unwrap_or_default(),
478                            provider: row.get(7)?,
479                            token_endpoint_auth_method: row.get(8)?,
480                        })
481                    },
482                )
483                .optional()
484                .map_err(sqlite_error)?;
485
486            // Decrypt provider_refresh_token if present and an enc key is
487            // configured.  maybe_decrypt_bound is a no-op for plaintext
488            // values, so this is safe to call unconditionally once a row is
489            // found.  The AAD re-derives the row identity from the same hash
490            // used for the lookup, so a ciphertext transplanted onto a
491            // different row fails authentication here.
492            match row {
493                Some(mut r) => {
494                    if let Some(raw) = r.provider_refresh_token.as_deref() {
495                        r.provider_refresh_token = Some(maybe_decrypt_bound(
496                            enc_key.as_deref(),
497                            raw,
498                            &refresh_token_aad(&hash),
499                        )?);
500                    }
501                    Ok(Some(r))
502                }
503                None => Ok(None),
504            }
505        })
506        .await
507    }
508
509    /// Read the `token_endpoint_auth_method` recorded on an unredeemed
510    /// authorization code, without consuming it.
511    ///
512    /// `Ok(None)` covers both "no such code" and a row written before schema
513    /// v5 recorded the method — callers must treat it as "unknown", never as
514    /// `"none"`.
515    pub async fn auth_code_client_auth_method(
516        &self,
517        code: &str,
518    ) -> Result<Option<String>, AuthError> {
519        self.client_auth_method(
520            "SELECT token_endpoint_auth_method FROM authorization_codes WHERE code = ?1",
521            code.to_string(),
522        )
523        .await
524    }
525
526    /// Read the `token_endpoint_auth_method` recorded on a refresh token.
527    ///
528    /// Same `Ok(None)` semantics as [`Self::auth_code_client_auth_method`].
529    pub async fn refresh_token_client_auth_method(
530        &self,
531        refresh_token: &str,
532    ) -> Result<Option<String>, AuthError> {
533        self.client_auth_method(
534            "SELECT token_endpoint_auth_method FROM refresh_tokens \
535             WHERE refresh_token_hash = ?1",
536            hash_token(refresh_token),
537        )
538        .await
539    }
540
541    /// Shared single-column lookup behind the two accessors above. The outer
542    /// `Option` (row present?) and the inner one (column non-NULL?) collapse
543    /// into one because both mean "resolve the client the way we always did".
544    async fn client_auth_method(
545        &self,
546        sql: &'static str,
547        key: String,
548    ) -> Result<Option<String>, AuthError> {
549        self.with_conn(move |conn| {
550            conn.query_row(sql, params![key], |row| row.get::<_, Option<String>>(0))
551                .optional()
552                .map(Option::flatten)
553                .map_err(sqlite_error)
554        })
555        .await
556    }
557
558    /// Whether any unexpired refresh token has ever been issued, for
559    /// any client. This is a single-tenant, admin-only gateway, so "someone
560    /// already completed the Google consent screen once" is a reasonable
561    /// proxy for "we don't need to force full re-consent again" without
562    /// having to know which subject is about to authenticate.
563    ///
564    /// No longer called internally — `authorize()` now uses the
565    /// provider-scoped [`Self::has_any_refresh_token_for_provider`] instead
566    /// (this unscoped version incorrectly treats "some OTHER provider
567    /// already has a refresh token on file" as a reason to skip forced
568    /// consent on a user's very first login with a *different* provider).
569    /// Retained as general-purpose public API for other consumers of this
570    /// shared crate, not because removing the internal call site was an
571    /// accident — do not assume this is dead code to delete.
572    pub async fn has_any_refresh_token(&self) -> Result<bool, AuthError> {
573        let now = now_unix();
574        self.with_conn(move |conn| {
575            conn.query_row(
576                "SELECT EXISTS(SELECT 1 FROM refresh_tokens WHERE expires_at > ?1)",
577                params![now],
578                |row| row.get::<_, i64>(0),
579            )
580            .map(|count| count != 0)
581            .map_err(sqlite_error)
582        })
583        .await
584    }
585
586    /// Same as [`Self::has_any_refresh_token`], scoped to one provider.
587    ///
588    /// `authorize()` (Task 11) uses this instead of the unscoped version to
589    /// decide whether to force the upstream consent screen — the unscoped
590    /// version incorrectly treats "Google already has a refresh token on
591    /// file" as a reason to skip forced consent on a user's very first
592    /// Authelia or GitHub login, silently degrading that new provider's
593    /// first session to no local refresh token.
594    pub async fn has_any_refresh_token_for_provider(
595        &self,
596        provider: &str,
597    ) -> Result<bool, AuthError> {
598        let provider = provider.to_string();
599        let now = now_unix();
600        self.with_conn(move |conn| {
601            conn.query_row(
602                "SELECT EXISTS(SELECT 1 FROM refresh_tokens WHERE provider = ?1 AND expires_at > ?2)",
603                params![provider, now],
604                |row| row.get::<_, i64>(0),
605            )
606            .map(|count| count != 0)
607            .map_err(sqlite_error)
608        })
609        .await
610    }
611
612    /// Run an arbitrary SQL batch against the store — test fixtures only.
613    ///
614    /// Gated behind `cfg(any(test, debug_assertions))` (deliberately not a
615    /// Cargo feature, mirroring `upstream::cache`'s test seam) so an
616    /// arbitrary-SQL execution method can never ship in
617    /// `--all-features --release` artifacts.
618    #[cfg(any(test, debug_assertions))]
619    pub async fn execute_test_statement(&self, sql: &str) -> Result<(), AuthError> {
620        let sql = sql.to_string();
621        self.with_conn(move |conn| conn.execute_batch(&sql).map_err(sqlite_error))
622            .await
623    }
624
625    pub async fn count_pending_oauth_states(&self) -> Result<usize, AuthError> {
626        let now = now_unix();
627        self.with_conn(move |conn| {
628            let authorization_requests: i64 = conn
629                .query_row(
630                    "SELECT COUNT(*) FROM authorization_requests WHERE expires_at > ?1",
631                    params![now],
632                    |row| row.get(0),
633                )
634                .map_err(sqlite_error)?;
635            let browser_login_states: i64 = conn
636                .query_row(
637                    "SELECT COUNT(*) FROM browser_login_states WHERE expires_at > ?1",
638                    params![now],
639                    |row| row.get(0),
640                )
641                .map_err(sqlite_error)?;
642            let native_authorization_results: i64 = conn
643                .query_row(
644                    "SELECT COUNT(*) FROM native_authorization_results WHERE expires_at > ?1",
645                    params![now],
646                    |row| row.get(0),
647                )
648                .map_err(sqlite_error)?;
649            Ok(
650                (authorization_requests + browser_login_states + native_authorization_results)
651                    as usize,
652            )
653        })
654        .await
655    }
656
657    /// Delete expired rows from all short-lived tables. Also drops upstream OAuth
658    /// credential rows whose access token has expired AND have no refresh token
659    /// available for re-use (SEC-9). Returns the total number of deleted rows.
660    pub async fn cleanup_expired(&self) -> Result<u64, AuthError> {
661        let now = now_unix();
662        self.with_conn(move |conn| {
663            let mut total: u64 = 0;
664            for table in [
665                "authorization_requests",
666                "authorization_codes",
667                "refresh_tokens",
668                "browser_sessions",
669                "browser_login_states",
670                "native_authorization_results",
671            ] {
672                let deleted = conn
673                    .execute(
674                        &format!("DELETE FROM {table} WHERE expires_at <= ?1"),
675                        params![now],
676                    )
677                    .map_err(sqlite_error)?;
678                total += deleted as u64;
679            }
680            let deleted = conn
681                .execute(
682                    "DELETE FROM upstream_oauth_state WHERE expires_at <= ?1",
683                    params![now],
684                )
685                .map_err(sqlite_error)?;
686            total += deleted as u64;
687            let deleted = conn
688                .execute(
689                    "DELETE FROM upstream_oauth_credentials
690                     WHERE access_token_expires_at <= ?1 AND refresh_token_present = 0",
691                    params![now],
692                )
693                .map_err(sqlite_error)?;
694            total += deleted as u64;
695            Ok(total)
696        })
697        .await
698    }
699
700    /// Add an email address to the allowlist.
701    ///
702    /// `email` is normalised to lowercase before storage. Returns
703    /// `AuthError::Validation` if the email is already present.
704    pub async fn add_allowed_user(
705        &self,
706        email: &str,
707        added_by: &str,
708        created_at: i64,
709    ) -> Result<(), AuthError> {
710        let email = email.to_lowercase();
711        let fp = fingerprint(&email);
712        let added_by = added_by.to_string();
713        self.with_conn(move |conn| {
714            let changed = conn
715                .execute(
716                    "INSERT INTO allowed_users (email, added_by, created_at)
717                     VALUES (?1, ?2, ?3)",
718                    params![email, added_by, created_at],
719                )
720                .map_err(|error| match error {
721                    rusqlite::Error::SqliteFailure(ref e, _)
722                        if e.code == rusqlite::ErrorCode::ConstraintViolation =>
723                    {
724                        AuthError::Validation(format!(
725                            "email fingerprint {fp} is already in the allowlist"
726                        ))
727                    }
728                    other => sqlite_error(other),
729                })?;
730            debug_assert_eq!(changed, 1);
731            Ok(())
732        })
733        .await
734    }
735
736    /// Remove an email address from the allowlist.
737    ///
738    /// Idempotent: returns `Ok(())` even if the email was not present.
739    pub async fn remove_allowed_user(&self, email: &str) -> Result<(), AuthError> {
740        let email = email.to_lowercase();
741        self.with_conn(move |conn| {
742            conn.execute("DELETE FROM allowed_users WHERE email = ?1", params![email])
743                .map_err(sqlite_error)?;
744            Ok(())
745        })
746        .await
747    }
748
749    /// Return all allowlist rows ordered by `created_at ASC`.
750    pub async fn list_allowed_users(&self) -> Result<Vec<AllowedUserRow>, AuthError> {
751        self.with_conn(move |conn| {
752            let mut stmt = conn
753                .prepare(
754                    "SELECT email, added_by, created_at
755                     FROM allowed_users
756                     ORDER BY created_at ASC",
757                )
758                .map_err(sqlite_error)?;
759            let rows = stmt
760                .query_map([], row_to_allowed_user)
761                .map_err(sqlite_error)?
762                .collect::<rusqlite::Result<Vec<_>>>()
763                .map_err(sqlite_error)?;
764            Ok(rows)
765        })
766        .await
767    }
768
769    async fn with_conn<T, F>(&self, op: F) -> Result<T, AuthError>
770    where
771        T: Send + 'static,
772        F: FnOnce(&Connection) -> Result<T, AuthError> + Send + 'static,
773    {
774        let conns = Arc::clone(&self.conns);
775        let path = Arc::clone(&self.path);
776        let len = conns.len();
777        let idx = self.next_conn.fetch_add(1, Ordering::Relaxed) % len;
778        tokio::task::spawn_blocking(move || {
779            let mut guard = conns[idx]
780                .lock()
781                .map_err(|_| AuthError::Storage("sqlite mutex poisoned".to_string()))?;
782            validate_or_reopen_connection(&mut guard, path.as_ref())?;
783            op(&guard)
784        })
785        .await
786        .map_err(|error| AuthError::Storage(format!("sqlite task failed: {error}")))?
787    }
788
789    #[cfg(test)]
790    fn connection_count(&self) -> usize {
791        self.conns.len()
792    }
793}
794
795fn open_connections(path: &Path, count: usize) -> Result<Vec<Connection>, AuthError> {
796    (0..count).map(|_| open_connection(path)).collect()
797}
798
799#[allow(clippy::too_many_lines)]
800fn open_connection(path: &Path) -> Result<Connection, AuthError> {
801    if let Some(parent) = path.parent() {
802        std::fs::create_dir_all(parent).map_err(|error| {
803            AuthError::Storage(format!(
804                "create auth database directory `{}`: {error}",
805                parent.display()
806            ))
807        })?;
808    }
809
810    let existed = path.exists();
811    if existed {
812        ensure_restrictive_permissions(path)?;
813    }
814
815    let conn = Connection::open(path).map_err(sqlite_error)?;
816    conn.busy_timeout(std::time::Duration::from_millis(SQLITE_BUSY_TIMEOUT_MS))
817        .map_err(sqlite_error)?;
818    conn.pragma_update(None, "journal_mode", "WAL")
819        .map_err(sqlite_error)?;
820    conn.pragma_update(None, "foreign_keys", "ON")
821        .map_err(sqlite_error)?;
822    conn.execute_batch(
823        "CREATE TABLE IF NOT EXISTS registered_clients (
824            client_id TEXT PRIMARY KEY,
825            redirect_uris TEXT NOT NULL,
826            created_at INTEGER NOT NULL,
827            token_endpoint_auth_method TEXT NOT NULL DEFAULT 'none',
828            jwks TEXT
829        );
830        CREATE TABLE IF NOT EXISTS authorization_requests (
831            state TEXT PRIMARY KEY,
832            client_id TEXT NOT NULL,
833            redirect_uri TEXT NOT NULL,
834            client_state TEXT NOT NULL,
835            resource TEXT NOT NULL DEFAULT '',
836            scope TEXT NOT NULL,
837            provider TEXT NOT NULL DEFAULT 'google',
838            provider_code_verifier TEXT NOT NULL,
839            code_challenge TEXT NOT NULL,
840            code_challenge_method TEXT NOT NULL,
841            created_at INTEGER NOT NULL,
842            expires_at INTEGER NOT NULL,
843            token_endpoint_auth_method TEXT
844        );
845        CREATE TABLE IF NOT EXISTS authorization_codes (
846            code TEXT PRIMARY KEY,
847            client_id TEXT NOT NULL,
848            subject TEXT NOT NULL,
849            redirect_uri TEXT NOT NULL,
850            resource TEXT NOT NULL DEFAULT '',
851            scope TEXT NOT NULL,
852            provider TEXT NOT NULL DEFAULT 'google',
853            code_challenge TEXT NOT NULL,
854            code_challenge_method TEXT NOT NULL,
855            provider_refresh_token TEXT,
856            created_at INTEGER NOT NULL,
857            expires_at INTEGER NOT NULL,
858            token_endpoint_auth_method TEXT
859        );
860        CREATE TABLE IF NOT EXISTS refresh_tokens (
861            refresh_token_hash TEXT PRIMARY KEY,
862            client_id TEXT NOT NULL,
863            subject TEXT NOT NULL,
864            resource TEXT NOT NULL DEFAULT '',
865            scope TEXT NOT NULL,
866            provider TEXT NOT NULL DEFAULT 'google',
867            provider_refresh_token TEXT,
868            created_at INTEGER NOT NULL,
869            expires_at INTEGER NOT NULL,
870            token_endpoint_auth_method TEXT
871        );
872        CREATE TABLE IF NOT EXISTS browser_sessions (
873            session_id TEXT PRIMARY KEY,
874            subject TEXT NOT NULL,
875            email TEXT,
876            csrf_token TEXT NOT NULL,
877            created_at INTEGER NOT NULL,
878            expires_at INTEGER NOT NULL
879        );
880        CREATE TABLE IF NOT EXISTS browser_login_states (
881            state TEXT PRIMARY KEY,
882            return_to TEXT NOT NULL,
883            provider TEXT NOT NULL DEFAULT 'google',
884            provider_code_verifier TEXT NOT NULL,
885            created_at INTEGER NOT NULL,
886            expires_at INTEGER NOT NULL
887        );
888        CREATE TABLE IF NOT EXISTS native_authorization_results (
889            state TEXT PRIMARY KEY,
890            code TEXT NOT NULL,
891            created_at INTEGER NOT NULL,
892            expires_at INTEGER NOT NULL
893        );
894        CREATE TABLE IF NOT EXISTS upstream_oauth_credentials (
895            upstream_name             TEXT NOT NULL,
896            subject                   TEXT NOT NULL,
897            issuer                    TEXT NOT NULL DEFAULT '',
898            client_id                 TEXT NOT NULL,
899            granted_scopes_json       TEXT NOT NULL,
900            token_blob                BLOB NOT NULL,
901            token_blob_nonce          BLOB NOT NULL,
902            token_received_at         INTEGER NOT NULL,
903            access_token_expires_at   INTEGER NOT NULL,
904            refresh_token_present     INTEGER NOT NULL,
905            PRIMARY KEY (upstream_name, subject)
906        ) WITHOUT ROWID;
907        CREATE TABLE IF NOT EXISTS upstream_oauth_state (
908            upstream_name        TEXT NOT NULL,
909            subject              TEXT NOT NULL,
910            csrf_token           TEXT NOT NULL,
911            pkce_verifier        TEXT NOT NULL,
912            expected_issuer      TEXT,
913            require_issuer       INTEGER NOT NULL DEFAULT 0,
914            requested_scopes_json TEXT NOT NULL DEFAULT '[]',
915            created_at           INTEGER NOT NULL,
916            expires_at      INTEGER NOT NULL,
917            PRIMARY KEY (upstream_name, subject, csrf_token)
918        ) WITHOUT ROWID;
919        CREATE TABLE IF NOT EXISTS upstream_oauth_dynamic_clients (
920            upstream_name   TEXT NOT NULL,
921            subject         TEXT NOT NULL,
922            client_id       TEXT NOT NULL,
923            issuer          TEXT NOT NULL DEFAULT '',
924            created_at      INTEGER NOT NULL,
925            PRIMARY KEY (upstream_name, subject)
926        ) WITHOUT ROWID;
927        CREATE TABLE IF NOT EXISTS allowed_users (
928            email       TEXT PRIMARY KEY NOT NULL,
929            added_by    TEXT NOT NULL,
930            created_at  INTEGER NOT NULL
931        );
932        CREATE TABLE IF NOT EXISTS assertion_jtis (
933            issuer TEXT NOT NULL,
934            jti TEXT NOT NULL,
935            expires_at INTEGER NOT NULL,
936            PRIMARY KEY (issuer, jti)
937        );
938        CREATE INDEX IF NOT EXISTS idx_assertion_jtis_expiry
939            ON assertion_jtis(expires_at);",
940    )
941    .map_err(sqlite_error)?;
942    add_column_if_missing(
943        &conn,
944        "registered_clients",
945        "token_endpoint_auth_method",
946        "TEXT NOT NULL DEFAULT 'none'",
947    )?;
948    add_column_if_missing(&conn, "registered_clients", "jwks", "TEXT")?;
949    add_column_if_missing(
950        &conn,
951        "authorization_requests",
952        "resource",
953        "TEXT NOT NULL DEFAULT ''",
954    )?;
955    add_column_if_missing(
956        &conn,
957        "authorization_codes",
958        "resource",
959        "TEXT NOT NULL DEFAULT ''",
960    )?;
961    add_column_if_missing(
962        &conn,
963        "refresh_tokens",
964        "resource",
965        "TEXT NOT NULL DEFAULT ''",
966    )?;
967    add_column_if_missing(
968        &conn,
969        "authorization_requests",
970        "provider",
971        "TEXT NOT NULL DEFAULT 'google'",
972    )?;
973    add_column_if_missing(
974        &conn,
975        "authorization_codes",
976        "provider",
977        "TEXT NOT NULL DEFAULT 'google'",
978    )?;
979    add_column_if_missing(
980        &conn,
981        "refresh_tokens",
982        "provider",
983        "TEXT NOT NULL DEFAULT 'google'",
984    )?;
985    add_column_if_missing(
986        &conn,
987        "browser_login_states",
988        "provider",
989        "TEXT NOT NULL DEFAULT 'google'",
990    )?;
991
992    if !existed {
993        set_restrictive_permissions(path)?;
994    }
995    ensure_restrictive_permissions(path)?;
996
997    run_migrations(&conn)?;
998
999    Ok(conn)
1000}
1001
1002/// AAD binding an encrypted `provider_refresh_token` to its row identity.
1003///
1004/// The refresh-token hash is the table's primary key and is derivable at
1005/// both encrypt time (upsert/rotate hash the plaintext token before insert)
1006/// and decrypt time (lookup is keyed by the same hash), so a ciphertext
1007/// copied onto a row with a different hash fails authentication.  Mirrors
1008/// the `key=value` AAD shape used by `upstream::store::credential_aad`.
1009fn refresh_token_aad(refresh_token_hash: &str) -> Vec<u8> {
1010    format!("refresh_token_hash={refresh_token_hash}").into_bytes()
1011}
1012
1013fn hash_token(token: &str) -> String {
1014    let digest = Sha256::digest(token.as_bytes());
1015    let mut hex = String::with_capacity(64);
1016    for byte in &digest {
1017        let _ = write!(&mut hex, "{byte:02x}");
1018    }
1019    hex
1020}
1021
1022fn validate_or_reopen_connection(conn: &mut Connection, path: &Path) -> Result<(), AuthError> {
1023    let Err(error) = conn.query_row("SELECT 1", [], |row| row.get::<_, i64>(0)) else {
1024        return Ok(());
1025    };
1026    warn!(
1027        path = %path.display(),
1028        error = %error,
1029        "stale sqlite connection detected, reopening"
1030    );
1031
1032    *conn = open_connection(path)?;
1033    conn.query_row("SELECT 1", [], |row| row.get::<_, i64>(0))
1034        .map(|_| ())
1035        .map_err(sqlite_error)
1036}
1037
1038#[allow(clippy::needless_pass_by_value)]
1039fn sqlite_error(error: rusqlite::Error) -> AuthError {
1040    AuthError::Storage(format!("sqlite error: {error}"))
1041}
1042
1043#[cfg(test)]
1044#[path = "sqlite_tests.rs"]
1045mod tests;
1046
1047#[cfg(test)]
1048#[path = "sqlite_migration_tests.rs"]
1049mod migration_tests;