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, BrowserLoginStateRow,
15    BrowserSessionRow, NativeAuthorizationResultRow, RefreshTokenRow, RegisteredClient,
16    UpstreamOauthCredentialRow, UpstreamOauthStateRow,
17};
18
19#[path = "sqlite_rows.rs"]
20mod sqlite_rows;
21use sqlite_rows::{
22    row_to_allowed_user, row_to_authorization_code, row_to_authorization_request,
23    row_to_browser_login_state, row_to_browser_session, row_to_native_authorization_result,
24    row_to_upstream_oauth_credentials, row_to_upstream_oauth_state,
25};
26
27const UPSTREAM_OAUTH_STATE_MAX_TTL_SECS: i64 = 600;
28/// Schema version for the `PRAGMA user_version` migration guard.
29/// Increment this whenever a migration step is added to `run_migrations`.
30const SCHEMA_VERSION: i64 = 2;
31
32use crate::util::{
33    ensure_restrictive_permissions, fingerprint, now_unix, set_restrictive_permissions,
34};
35
36const SQLITE_BUSY_TIMEOUT_MS: u64 = 5_000;
37const SQLITE_POOL_SIZE: usize = 4;
38
39#[derive(Clone)]
40pub struct SqliteStore {
41    conns: Arc<Vec<Mutex<Connection>>>,
42    next_conn: Arc<AtomicUsize>,
43    path: Arc<PathBuf>,
44    /// Optional at-rest encryption key for upstream provider refresh tokens.
45    enc_key: Option<Arc<TokenEncryptionKey>>,
46}
47
48impl std::fmt::Debug for SqliteStore {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.debug_struct("SqliteStore")
51            .field("path", &self.path)
52            .field("enc_key", &self.enc_key.as_ref().map(|_| "<redacted>"))
53            .finish_non_exhaustive()
54    }
55}
56
57impl SqliteStore {
58    pub async fn open(path: PathBuf) -> Result<Self, AuthError> {
59        Self::open_with_key(path, None).await
60    }
61
62    pub async fn open_with_key(
63        path: PathBuf,
64        enc_key: Option<TokenEncryptionKey>,
65    ) -> Result<Self, AuthError> {
66        let path_for_open = path.clone();
67        let conns = tokio::task::spawn_blocking(move || {
68            open_connections(path_for_open.as_path(), SQLITE_POOL_SIZE)
69        })
70        .await;
71        let store = match conns {
72            Ok(result) => result,
73            Err(error) => Err(AuthError::Storage(format!(
74                "sqlite open task failed: {error}"
75            ))),
76        }
77        .map(|conns| Self {
78            conns: Arc::new(conns.into_iter().map(Mutex::new).collect()),
79            next_conn: Arc::new(AtomicUsize::new(0)),
80            path: Arc::new(path),
81            enc_key: enc_key.map(Arc::new),
82        })?;
83
84        store.cleanup_expired().await?;
85        Ok(store)
86    }
87
88    pub async fn pragma(&self, name: &str) -> Result<String, AuthError> {
89        let pragma = match name {
90            "journal_mode" | "busy_timeout" | "foreign_keys" => name.to_string(),
91            other => {
92                return Err(AuthError::Config(format!(
93                    "unsupported pragma query `{other}`"
94                )));
95            }
96        };
97
98        self.with_conn(move |conn| {
99            conn.query_row(&format!("PRAGMA {pragma};"), [], |row| {
100                row.get::<_, Value>(0)
101            })
102            .map(|value| match value {
103                Value::Text(text) => text,
104                Value::Integer(int) => int.to_string(),
105                other => format!("{other:?}"),
106            })
107            .map_err(sqlite_error)
108        })
109        .await
110    }
111
112    pub async fn register_client(&self, client: RegisteredClient) -> Result<(), AuthError> {
113        self.with_conn(move |conn| {
114            let redirect_uris = serde_json::to_string(&client.redirect_uris)
115                .map_err(|error| AuthError::Storage(format!("serialize redirect_uris: {error}")))?;
116            conn.execute(
117                "INSERT INTO registered_clients (client_id, redirect_uris, created_at)
118                 VALUES (?1, ?2, ?3)
119                 ON CONFLICT(client_id) DO UPDATE SET
120                    redirect_uris = excluded.redirect_uris,
121                    created_at = excluded.created_at",
122                params![client.client_id, redirect_uris, client.created_at],
123            )
124            .map_err(sqlite_error)?;
125            Ok(())
126        })
127        .await
128    }
129
130    pub async fn find_client(
131        &self,
132        client_id: &str,
133    ) -> Result<Option<RegisteredClient>, AuthError> {
134        let client_id = client_id.to_string();
135        self.with_conn(move |conn| {
136            conn.query_row(
137                "SELECT client_id, redirect_uris, created_at
138                 FROM registered_clients
139                 WHERE client_id = ?1",
140                params![client_id],
141                |row| {
142                    let redirect_uris: String = row.get(1)?;
143                    let redirect_uris = serde_json::from_str(&redirect_uris).map_err(|error| {
144                        rusqlite::Error::FromSqlConversionFailure(
145                            1,
146                            rusqlite::types::Type::Text,
147                            Box::new(error),
148                        )
149                    })?;
150                    Ok(RegisteredClient {
151                        client_id: row.get(0)?,
152                        redirect_uris,
153                        created_at: row.get(2)?,
154                    })
155                },
156            )
157            .optional()
158            .map_err(sqlite_error)
159        })
160        .await
161    }
162
163    pub async fn insert_authorization_request(
164        &self,
165        request: AuthorizationRequestRow,
166    ) -> Result<(), AuthError> {
167        self.with_conn(move |conn| {
168            conn.execute(
169                "INSERT INTO authorization_requests (
170                    state, client_id, redirect_uri, client_state, resource, scope, provider_code_verifier,
171                    code_challenge, code_challenge_method, created_at, expires_at, provider
172                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
173                params![
174                    request.state,
175                    request.client_id,
176                    request.redirect_uri,
177                    request.client_state,
178                    request.resource,
179                    request.scope,
180                    request.provider_code_verifier,
181                    request.code_challenge,
182                    request.code_challenge_method,
183                    request.created_at,
184                    request.expires_at,
185                    request.provider,
186                ],
187            )
188            .map_err(sqlite_error)?;
189            Ok(())
190        })
191        .await
192    }
193
194    pub async fn take_authorization_request(
195        &self,
196        state: &str,
197    ) -> Result<AuthorizationRequestRow, AuthError> {
198        let state = state.to_string();
199        let now = now_unix();
200        self.with_conn(move |conn| {
201            conn.query_row(
202                "DELETE FROM authorization_requests
203                 WHERE state = ?1
204                   AND expires_at > ?2
205                 RETURNING state, client_id, redirect_uri, client_state, scope, provider_code_verifier,
206                           code_challenge, code_challenge_method, created_at, expires_at, resource, provider",
207                params![state, now],
208                row_to_authorization_request,
209            )
210            .map_err(|error| match error {
211                rusqlite::Error::QueryReturnedNoRows => AuthError::InvalidGrant(
212                    "authorization state is missing, expired, or already used".to_string(),
213                ),
214                other => sqlite_error(other),
215            })
216        })
217        .await
218    }
219
220    pub async fn insert_auth_code(&self, code: AuthorizationCodeRow) -> Result<(), AuthError> {
221        self.with_conn(move |conn| {
222            conn.execute(
223                "INSERT INTO authorization_codes (
224                    code, client_id, subject, redirect_uri, resource, scope,
225                    code_challenge, code_challenge_method, provider_refresh_token,
226                    created_at, expires_at, provider
227                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
228                params![
229                    code.code,
230                    code.client_id,
231                    code.subject,
232                    code.redirect_uri,
233                    code.resource,
234                    code.scope,
235                    code.code_challenge,
236                    code.code_challenge_method,
237                    code.provider_refresh_token,
238                    code.created_at,
239                    code.expires_at,
240                    code.provider,
241                ],
242            )
243            .map_err(sqlite_error)?;
244            Ok(())
245        })
246        .await
247    }
248
249    pub async fn redeem_auth_code(&self, code: &str) -> Result<AuthorizationCodeRow, AuthError> {
250        let code = code.to_string();
251        let now = now_unix();
252        self.with_conn(move |conn| {
253            conn.query_row(
254                "DELETE FROM authorization_codes
255                 WHERE code = ?1
256                   AND expires_at > ?2
257                 RETURNING code, client_id, subject, redirect_uri, scope,
258                           code_challenge, code_challenge_method, provider_refresh_token,
259                           created_at, expires_at, resource, provider",
260                params![code, now],
261                row_to_authorization_code,
262            )
263            .map_err(|error| match error {
264                rusqlite::Error::QueryReturnedNoRows => AuthError::InvalidGrant(
265                    "authorization code is missing, expired, or already redeemed".to_string(),
266                ),
267                other => sqlite_error(other),
268            })
269        })
270        .await
271    }
272
273    /// Insert a new refresh token row, storing a SHA-256 hash of the raw token
274    /// as the primary key.  The plaintext token is **never** persisted; only the
275    /// caller-returned value contains it.  If an encryption key is configured,
276    /// `provider_refresh_token` is encrypted at rest before storage.
277    ///
278    /// Use [`Self::rotate_refresh_token`] instead of calling this twice when replacing
279    /// an existing token — that method performs the swap atomically.
280    pub async fn upsert_refresh_token(&self, token: RefreshTokenRow) -> Result<(), AuthError> {
281        let hash = hash_token(&token.refresh_token);
282        let encrypted_provider_rt = token
283            .provider_refresh_token
284            .as_deref()
285            .map(|raw| maybe_encrypt_bound(self.enc_key.as_deref(), raw, &refresh_token_aad(&hash)))
286            .transpose()?;
287        self.with_conn(move |conn| {
288            conn.execute(
289                "INSERT INTO refresh_tokens (
290                    refresh_token_hash, client_id, subject, resource, scope,
291                    provider_refresh_token, created_at, expires_at, provider
292                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
293                 ON CONFLICT(refresh_token_hash) DO UPDATE SET
294                    client_id = excluded.client_id,
295                    subject = excluded.subject,
296                    resource = excluded.resource,
297                    scope = excluded.scope,
298                    provider_refresh_token = excluded.provider_refresh_token,
299                    created_at = excluded.created_at,
300                    expires_at = excluded.expires_at,
301                    provider = excluded.provider",
302                params![
303                    hash,
304                    token.client_id,
305                    token.subject,
306                    token.resource,
307                    token.scope,
308                    encrypted_provider_rt,
309                    token.created_at,
310                    token.expires_at,
311                    token.provider,
312                ],
313            )
314            .map_err(sqlite_error)?;
315            Ok(())
316        })
317        .await
318    }
319
320    /// Atomically replace an existing refresh token with a new one in a single
321    /// SQLite transaction.  The old token is deleted and the new token is
322    /// inserted; if the old token is not found or has expired the operation
323    /// fails without inserting the new row (replay-safe).
324    ///
325    /// Both the DELETE and the INSERT are wrapped in an explicit `BEGIN` /
326    /// `COMMIT` so a crash between the two statements cannot leave the database
327    /// without a valid refresh token.
328    ///
329    /// Returns the newly issued `RefreshTokenRow` (with `refresh_token` set to
330    /// the new plaintext value) on success.
331    pub async fn rotate_refresh_token(
332        &self,
333        old_token: &str,
334        new_token: RefreshTokenRow,
335    ) -> Result<Option<RefreshTokenRow>, AuthError> {
336        let old_hash = hash_token(old_token);
337        let new_hash = hash_token(&new_token.refresh_token);
338        let now = now_unix();
339        let encrypted_provider_rt = new_token
340            .provider_refresh_token
341            .as_deref()
342            .map(|raw| {
343                maybe_encrypt_bound(self.enc_key.as_deref(), raw, &refresh_token_aad(&new_hash))
344            })
345            .transpose()?;
346        self.with_conn(move |conn| {
347            conn.execute_batch("BEGIN").map_err(sqlite_error)?;
348
349            let delete_result = conn
350                .execute(
351                    "DELETE FROM refresh_tokens
352                     WHERE refresh_token_hash = ?1
353                       AND expires_at > ?2",
354                    params![old_hash, now],
355                )
356                .map_err(sqlite_error);
357
358            let deleted = match delete_result {
359                Ok(n) => n,
360                Err(e) => {
361                    drop(conn.execute_batch("ROLLBACK"));
362                    return Err(e);
363                }
364            };
365
366            if deleted == 0 {
367                // Old token not found or already expired — rollback and reject.
368                drop(conn.execute_batch("ROLLBACK"));
369                return Ok(None);
370            }
371
372            let insert_result = conn
373                .execute(
374                    "INSERT INTO refresh_tokens (
375                    refresh_token_hash, client_id, subject, resource, scope,
376                    provider_refresh_token, created_at, expires_at, provider
377                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
378                    params![
379                        new_hash,
380                        new_token.client_id,
381                        new_token.subject,
382                        new_token.resource,
383                        new_token.scope,
384                        encrypted_provider_rt,
385                        new_token.created_at,
386                        new_token.expires_at,
387                        new_token.provider,
388                    ],
389                )
390                .map_err(sqlite_error);
391
392            match insert_result {
393                Ok(_) => {
394                    conn.execute_batch("COMMIT").map_err(sqlite_error)?;
395                    Ok(Some(new_token))
396                }
397                Err(e) => {
398                    drop(conn.execute_batch("ROLLBACK"));
399                    Err(e)
400                }
401            }
402        })
403        .await
404    }
405
406    pub async fn find_refresh_token(
407        &self,
408        refresh_token: &str,
409    ) -> Result<Option<RefreshTokenRow>, AuthError> {
410        let hash = hash_token(refresh_token);
411        // Keep the plaintext value in memory so the caller receives a row with
412        // `refresh_token` populated (the DB never stores it).
413        let plaintext = refresh_token.to_string();
414        let now = now_unix();
415        let enc_key = self.enc_key.clone();
416        self.with_conn(move |conn| {
417            let row = conn
418                .query_row(
419                    "SELECT client_id, subject, scope,
420                        provider_refresh_token, created_at, expires_at, resource, provider
421                 FROM refresh_tokens
422                 WHERE refresh_token_hash = ?1
423                   AND expires_at > ?2",
424                    params![hash, now],
425                    |row| {
426                        Ok(RefreshTokenRow {
427                            refresh_token: plaintext.clone(),
428                            client_id: row.get(0)?,
429                            subject: row.get(1)?,
430                            scope: row.get(2)?,
431                            provider_refresh_token: row.get(3)?,
432                            created_at: row.get(4)?,
433                            expires_at: row.get(5)?,
434                            resource: row.get(6).unwrap_or_default(),
435                            provider: row.get(7)?,
436                        })
437                    },
438                )
439                .optional()
440                .map_err(sqlite_error)?;
441
442            // Decrypt provider_refresh_token if present and an enc key is
443            // configured.  maybe_decrypt_bound is a no-op for plaintext
444            // values, so this is safe to call unconditionally once a row is
445            // found.  The AAD re-derives the row identity from the same hash
446            // used for the lookup, so a ciphertext transplanted onto a
447            // different row fails authentication here.
448            match row {
449                Some(mut r) => {
450                    if let Some(raw) = r.provider_refresh_token.as_deref() {
451                        r.provider_refresh_token = Some(maybe_decrypt_bound(
452                            enc_key.as_deref(),
453                            raw,
454                            &refresh_token_aad(&hash),
455                        )?);
456                    }
457                    Ok(Some(r))
458                }
459                None => Ok(None),
460            }
461        })
462        .await
463    }
464
465    /// Whether any unexpired refresh token has ever been issued, for
466    /// any client. This is a single-tenant, admin-only gateway, so "someone
467    /// already completed the Google consent screen once" is a reasonable
468    /// proxy for "we don't need to force full re-consent again" without
469    /// having to know which subject is about to authenticate.
470    ///
471    /// No longer called internally — `authorize()` now uses the
472    /// provider-scoped [`Self::has_any_refresh_token_for_provider`] instead
473    /// (this unscoped version incorrectly treats "some OTHER provider
474    /// already has a refresh token on file" as a reason to skip forced
475    /// consent on a user's very first login with a *different* provider).
476    /// Retained as general-purpose public API for other consumers of this
477    /// shared crate, not because removing the internal call site was an
478    /// accident — do not assume this is dead code to delete.
479    pub async fn has_any_refresh_token(&self) -> Result<bool, AuthError> {
480        let now = now_unix();
481        self.with_conn(move |conn| {
482            conn.query_row(
483                "SELECT EXISTS(SELECT 1 FROM refresh_tokens WHERE expires_at > ?1)",
484                params![now],
485                |row| row.get::<_, i64>(0),
486            )
487            .map(|count| count != 0)
488            .map_err(sqlite_error)
489        })
490        .await
491    }
492
493    /// Same as [`Self::has_any_refresh_token`], scoped to one provider.
494    ///
495    /// `authorize()` (Task 11) uses this instead of the unscoped version to
496    /// decide whether to force the upstream consent screen — the unscoped
497    /// version incorrectly treats "Google already has a refresh token on
498    /// file" as a reason to skip forced consent on a user's very first
499    /// Authelia or GitHub login, silently degrading that new provider's
500    /// first session to no local refresh token.
501    pub async fn has_any_refresh_token_for_provider(
502        &self,
503        provider: &str,
504    ) -> Result<bool, AuthError> {
505        let provider = provider.to_string();
506        let now = now_unix();
507        self.with_conn(move |conn| {
508            conn.query_row(
509                "SELECT EXISTS(SELECT 1 FROM refresh_tokens WHERE provider = ?1 AND expires_at > ?2)",
510                params![provider, now],
511                |row| row.get::<_, i64>(0),
512            )
513            .map(|count| count != 0)
514            .map_err(sqlite_error)
515        })
516        .await
517    }
518
519    pub async fn upsert_browser_session(
520        &self,
521        session: BrowserSessionRow,
522    ) -> Result<(), AuthError> {
523        self.with_conn(move |conn| {
524            conn.execute(
525                "INSERT INTO browser_sessions (
526                    session_id, subject, email, csrf_token, created_at, expires_at
527                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
528                 ON CONFLICT(session_id) DO UPDATE SET
529                    subject = excluded.subject,
530                    email = excluded.email,
531                    csrf_token = excluded.csrf_token,
532                    created_at = excluded.created_at,
533                    expires_at = excluded.expires_at",
534                params![
535                    session.session_id,
536                    session.subject,
537                    session.email,
538                    session.csrf_token,
539                    session.created_at,
540                    session.expires_at,
541                ],
542            )
543            .map_err(sqlite_error)?;
544            Ok(())
545        })
546        .await
547    }
548
549    pub async fn find_browser_session(
550        &self,
551        session_id: &str,
552    ) -> Result<Option<BrowserSessionRow>, AuthError> {
553        let session_id = session_id.to_string();
554        let now = now_unix();
555        self.with_conn(move |conn| {
556            conn.query_row(
557                "SELECT session_id, subject, email, csrf_token, created_at, expires_at
558                 FROM browser_sessions
559                 WHERE session_id = ?1
560                   AND expires_at > ?2",
561                params![session_id, now],
562                row_to_browser_session,
563            )
564            .optional()
565            .map_err(sqlite_error)
566        })
567        .await
568    }
569
570    pub async fn revoke_browser_session(&self, session_id: &str) -> Result<(), AuthError> {
571        let session_id = session_id.to_string();
572        self.with_conn(move |conn| {
573            conn.execute(
574                "DELETE FROM browser_sessions WHERE session_id = ?1",
575                params![session_id],
576            )
577            .map_err(sqlite_error)?;
578            Ok(())
579        })
580        .await
581    }
582
583    /// Run an arbitrary SQL batch against the store — test fixtures only.
584    ///
585    /// Gated behind `cfg(any(test, debug_assertions))` (deliberately not a
586    /// Cargo feature, mirroring `upstream::cache`'s test seam) so an
587    /// arbitrary-SQL execution method can never ship in
588    /// `--all-features --release` artifacts.
589    #[cfg(any(test, debug_assertions))]
590    pub async fn execute_test_statement(&self, sql: &str) -> Result<(), AuthError> {
591        let sql = sql.to_string();
592        self.with_conn(move |conn| conn.execute_batch(&sql).map_err(sqlite_error))
593            .await
594    }
595
596    pub async fn insert_browser_login_state(
597        &self,
598        login: BrowserLoginStateRow,
599    ) -> Result<(), AuthError> {
600        self.with_conn(move |conn| {
601            conn.execute(
602                "INSERT INTO browser_login_states (
603                    state, return_to, provider_code_verifier, created_at, expires_at, provider
604                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
605                params![
606                    login.state,
607                    login.return_to,
608                    login.provider_code_verifier,
609                    login.created_at,
610                    login.expires_at,
611                    login.provider,
612                ],
613            )
614            .map_err(sqlite_error)?;
615            Ok(())
616        })
617        .await
618    }
619
620    pub async fn count_pending_oauth_states(&self) -> Result<usize, AuthError> {
621        let now = now_unix();
622        self.with_conn(move |conn| {
623            let authorization_requests: i64 = conn
624                .query_row(
625                    "SELECT COUNT(*) FROM authorization_requests WHERE expires_at > ?1",
626                    params![now],
627                    |row| row.get(0),
628                )
629                .map_err(sqlite_error)?;
630            let browser_login_states: i64 = conn
631                .query_row(
632                    "SELECT COUNT(*) FROM browser_login_states WHERE expires_at > ?1",
633                    params![now],
634                    |row| row.get(0),
635                )
636                .map_err(sqlite_error)?;
637            let native_authorization_results: i64 = conn
638                .query_row(
639                    "SELECT COUNT(*) FROM native_authorization_results WHERE expires_at > ?1",
640                    params![now],
641                    |row| row.get(0),
642                )
643                .map_err(sqlite_error)?;
644            Ok(
645                (authorization_requests + browser_login_states + native_authorization_results)
646                    as usize,
647            )
648        })
649        .await
650    }
651
652    pub async fn take_browser_login_state(
653        &self,
654        state: &str,
655    ) -> Result<Option<BrowserLoginStateRow>, AuthError> {
656        let state = state.to_string();
657        let now = now_unix();
658        self.with_conn(move |conn| {
659            conn.query_row(
660                "DELETE FROM browser_login_states
661                 WHERE state = ?1
662                   AND expires_at > ?2
663                 RETURNING state, return_to, provider_code_verifier, created_at, expires_at, provider",
664                params![state, now],
665                row_to_browser_login_state,
666            )
667            .optional()
668            .map_err(sqlite_error)
669        })
670        .await
671    }
672
673    /// Store a native-flow authorization code keyed by `state`, for the
674    /// polling desktop client to retrieve via `take_native_authorization_result`.
675    ///
676    /// Last-write-wins on a `state` collision (e.g. a client retrying
677    /// `/authorize` with the same `state` after a timeout): each row is
678    /// single-use (deleted on first successful poll), so overwriting with the
679    /// newest code is correct — silently dropping the newest code instead
680    /// (`DO NOTHING`) would leave the polling client hung until the row's TTL
681    /// expires, with no error surfaced anywhere.
682    pub async fn insert_native_authorization_result(
683        &self,
684        result: NativeAuthorizationResultRow,
685    ) -> Result<(), AuthError> {
686        self.with_conn(move |conn| {
687            conn.execute(
688                "INSERT INTO native_authorization_results (state, code, created_at, expires_at)
689                 VALUES (?1, ?2, ?3, ?4)
690                 ON CONFLICT(state) DO UPDATE SET
691                    code = excluded.code,
692                    created_at = excluded.created_at,
693                    expires_at = excluded.expires_at",
694                params![
695                    result.state,
696                    result.code,
697                    result.created_at,
698                    result.expires_at,
699                ],
700            )
701            .map_err(sqlite_error)?;
702            Ok(())
703        })
704        .await
705    }
706
707    /// One-shot read-and-delete of a pending native-flow authorization code.
708    pub async fn take_native_authorization_result(
709        &self,
710        state: &str,
711    ) -> Result<Option<NativeAuthorizationResultRow>, AuthError> {
712        let state = state.to_string();
713        let now = now_unix();
714        self.with_conn(move |conn| {
715            conn.query_row(
716                "DELETE FROM native_authorization_results
717                 WHERE state = ?1
718                   AND expires_at > ?2
719                 RETURNING state, code, created_at, expires_at",
720                params![state, now],
721                row_to_native_authorization_result,
722            )
723            .optional()
724            .map_err(sqlite_error)
725        })
726        .await
727    }
728
729    /// Delete expired rows from all short-lived tables. Also drops upstream OAuth
730    /// credential rows whose access token has expired AND have no refresh token
731    /// available for re-use (SEC-9). Returns the total number of deleted rows.
732    pub async fn cleanup_expired(&self) -> Result<u64, AuthError> {
733        let now = now_unix();
734        self.with_conn(move |conn| {
735            let mut total: u64 = 0;
736            for table in [
737                "authorization_requests",
738                "authorization_codes",
739                "refresh_tokens",
740                "browser_sessions",
741                "browser_login_states",
742                "native_authorization_results",
743            ] {
744                let deleted = conn
745                    .execute(
746                        &format!("DELETE FROM {table} WHERE expires_at <= ?1"),
747                        params![now],
748                    )
749                    .map_err(sqlite_error)?;
750                total += deleted as u64;
751            }
752            let deleted = conn
753                .execute(
754                    "DELETE FROM upstream_oauth_state WHERE expires_at <= ?1",
755                    params![now],
756                )
757                .map_err(sqlite_error)?;
758            total += deleted as u64;
759            let deleted = conn
760                .execute(
761                    "DELETE FROM upstream_oauth_credentials
762                     WHERE access_token_expires_at <= ?1 AND refresh_token_present = 0",
763                    params![now],
764                )
765                .map_err(sqlite_error)?;
766            total += deleted as u64;
767            Ok(total)
768        })
769        .await
770    }
771
772    pub async fn upsert_upstream_oauth_credentials(
773        &self,
774        row: UpstreamOauthCredentialRow,
775    ) -> Result<(), AuthError> {
776        self.with_conn(move |conn| {
777            conn.execute(
778                "INSERT INTO upstream_oauth_credentials (
779                    upstream_name, subject, client_id, granted_scopes_json,
780                    token_blob, token_blob_nonce, token_received_at,
781                    access_token_expires_at, refresh_token_present
782                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
783                 ON CONFLICT(upstream_name, subject) DO UPDATE SET
784                    client_id = excluded.client_id,
785                    granted_scopes_json = excluded.granted_scopes_json,
786                    token_blob = excluded.token_blob,
787                    token_blob_nonce = excluded.token_blob_nonce,
788                    token_received_at = excluded.token_received_at,
789                    access_token_expires_at = excluded.access_token_expires_at,
790                    refresh_token_present = excluded.refresh_token_present",
791                params![
792                    row.upstream_name,
793                    row.subject,
794                    row.client_id,
795                    row.granted_scopes_json,
796                    row.token_blob,
797                    row.token_blob_nonce,
798                    row.token_received_at,
799                    row.access_token_expires_at,
800                    i64::from(row.refresh_token_present),
801                ],
802            )
803            .map_err(sqlite_error)?;
804            Ok(())
805        })
806        .await
807    }
808
809    pub async fn find_upstream_oauth_credentials(
810        &self,
811        upstream_name: &str,
812        subject: &str,
813    ) -> Result<Option<UpstreamOauthCredentialRow>, AuthError> {
814        let upstream_name = upstream_name.to_string();
815        let subject = subject.to_string();
816        self.with_conn(move |conn| {
817            conn.query_row(
818                "SELECT upstream_name, subject, client_id, granted_scopes_json,
819                        token_blob, token_blob_nonce, token_received_at,
820                        access_token_expires_at, refresh_token_present
821                 FROM upstream_oauth_credentials
822                 WHERE upstream_name = ?1 AND subject = ?2",
823                params![upstream_name, subject],
824                row_to_upstream_oauth_credentials,
825            )
826            .optional()
827            .map_err(sqlite_error)
828        })
829        .await
830    }
831
832    pub async fn delete_upstream_oauth_credentials(
833        &self,
834        upstream_name: &str,
835        subject: &str,
836    ) -> Result<(), AuthError> {
837        let upstream_name = upstream_name.to_string();
838        let subject = subject.to_string();
839        self.with_conn(move |conn| {
840            conn.execute(
841                "DELETE FROM upstream_oauth_credentials
842                 WHERE upstream_name = ?1 AND subject = ?2",
843                params![upstream_name, subject],
844            )
845            .map_err(sqlite_error)?;
846            Ok(())
847        })
848        .await
849    }
850
851    pub async fn save_upstream_oauth_state(
852        &self,
853        row: UpstreamOauthStateRow,
854    ) -> Result<(), AuthError> {
855        if row.expires_at <= row.created_at
856            || row.expires_at - row.created_at > UPSTREAM_OAUTH_STATE_MAX_TTL_SECS
857        {
858            return Err(AuthError::InvalidGrant(
859                "state TTL exceeds 600s".to_string(),
860            ));
861        }
862        self.with_conn(move |conn| {
863            conn.execute(
864                "INSERT INTO upstream_oauth_state (
865                    upstream_name, subject, csrf_token, pkce_verifier, created_at, expires_at
866                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
867                params![
868                    row.upstream_name,
869                    row.subject,
870                    row.csrf_token,
871                    row.pkce_verifier,
872                    row.created_at,
873                    row.expires_at,
874                ],
875            )
876            .map_err(sqlite_error)?;
877            Ok(())
878        })
879        .await
880    }
881
882    pub async fn find_upstream_oauth_state_subject(
883        &self,
884        upstream_name: &str,
885        csrf_token: &str,
886        now: i64,
887    ) -> Result<Option<String>, AuthError> {
888        let upstream_name = upstream_name.to_string();
889        let csrf_token = csrf_token.to_string();
890        self.with_conn(move |conn| {
891            conn.query_row(
892                "SELECT subject
893                 FROM upstream_oauth_state
894                 WHERE upstream_name = ?1
895                   AND csrf_token = ?2
896                   AND expires_at > ?3",
897                params![upstream_name, csrf_token, now],
898                |row| row.get(0),
899            )
900            .optional()
901            .map_err(sqlite_error)
902        })
903        .await
904    }
905
906    /// Look up `(upstream_name, subject)` by `csrf_token` alone.
907    ///
908    /// Used by the OAuth callback handler to recover the upstream identity from
909    /// the state parameter without requiring the caller to know it upfront.
910    pub async fn find_upstream_oauth_state_owner(
911        &self,
912        csrf_token: &str,
913        now: i64,
914    ) -> Result<Option<(String, String)>, AuthError> {
915        let csrf_token = csrf_token.to_string();
916        self.with_conn(move |conn| {
917            conn.query_row(
918                "SELECT upstream_name, subject
919                 FROM upstream_oauth_state
920                 WHERE csrf_token = ?1
921                   AND expires_at > ?2",
922                params![csrf_token, now],
923                |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
924            )
925            .optional()
926            .map_err(sqlite_error)
927        })
928        .await
929    }
930
931    /// Delete a pending OAuth state token by CSRF token to foreclose replay attacks after exchange failure.
932    pub async fn delete_upstream_oauth_state_by_csrf(
933        &self,
934        csrf_token: &str,
935        now: i64,
936    ) -> Result<(), AuthError> {
937        let csrf_token = csrf_token.to_string();
938        self.with_conn(move |conn| {
939            conn.execute(
940                "DELETE FROM upstream_oauth_state
941                 WHERE csrf_token = ?1
942                   AND expires_at > ?2",
943                params![csrf_token, now],
944            )
945            .map_err(sqlite_error)?;
946            Ok(())
947        })
948        .await
949    }
950
951    /// Bind a dynamic OAuth `client_id` to a pending CSRF state row.
952    ///
953    /// Called by `begin_authorization` after generating the authorization URL
954    /// so that `complete_authorization_callback` can later look up which
955    /// `client_id` was used for this specific flow (lab-77y5.15).
956    pub async fn set_upstream_oauth_state_client_id(
957        &self,
958        upstream_name: &str,
959        csrf_token: &str,
960        client_id: &str,
961    ) -> Result<(), AuthError> {
962        let upstream_name = upstream_name.to_string();
963        let csrf_token = csrf_token.to_string();
964        let client_id = client_id.to_string();
965        self.with_conn(move |conn| {
966            conn.execute(
967                "UPDATE upstream_oauth_state
968                 SET dynamic_client_id = ?1
969                 WHERE upstream_name = ?2
970                   AND csrf_token = ?3",
971                params![client_id, upstream_name, csrf_token],
972            )
973            .map_err(sqlite_error)?;
974            Ok(())
975        })
976        .await
977    }
978
979    /// Retrieve the `dynamic_client_id` bound to a pending CSRF state row.
980    ///
981    /// Returns `None` when no row matches or the row has expired. Used by
982    /// `complete_authorization_callback` to recover the exact `client_id` that
983    /// was used when the authorization URL was generated (lab-77y5.15).
984    pub async fn get_upstream_oauth_state_client_id(
985        &self,
986        upstream_name: &str,
987        csrf_token: &str,
988        now: i64,
989    ) -> Result<Option<String>, AuthError> {
990        let upstream_name = upstream_name.to_string();
991        let csrf_token = csrf_token.to_string();
992        self.with_conn(move |conn| {
993            conn.query_row(
994                "SELECT dynamic_client_id
995                 FROM upstream_oauth_state
996                 WHERE upstream_name = ?1
997                   AND csrf_token = ?2
998                   AND expires_at > ?3",
999                params![upstream_name, csrf_token, now],
1000                |row| row.get::<_, Option<String>>(0),
1001            )
1002            .optional()
1003            .map(|opt| opt.flatten())
1004            .map_err(sqlite_error)
1005        })
1006        .await
1007    }
1008
1009    /// Atomic take-once via `DELETE ... RETURNING`.
1010    pub async fn take_upstream_oauth_state(
1011        &self,
1012        upstream_name: &str,
1013        subject: &str,
1014        csrf_token: &str,
1015        now: i64,
1016    ) -> Result<Option<UpstreamOauthStateRow>, AuthError> {
1017        let upstream_name = upstream_name.to_string();
1018        let subject = subject.to_string();
1019        let csrf_token = csrf_token.to_string();
1020        self.with_conn(move |conn| {
1021            conn.query_row(
1022                "DELETE FROM upstream_oauth_state
1023                 WHERE upstream_name = ?1
1024                   AND subject = ?2
1025                   AND csrf_token = ?3
1026                   AND expires_at > ?4
1027                 RETURNING upstream_name, subject, csrf_token, pkce_verifier, created_at, expires_at",
1028                params![upstream_name, subject, csrf_token, now],
1029                row_to_upstream_oauth_state,
1030            )
1031            .optional()
1032            .map_err(sqlite_error)
1033        })
1034        .await
1035    }
1036
1037    pub async fn save_dynamic_client_registration(
1038        &self,
1039        upstream_name: &str,
1040        subject: &str,
1041        client_id: &str,
1042    ) -> Result<(), AuthError> {
1043        let upstream_name = upstream_name.to_string();
1044        let subject = subject.to_string();
1045        let client_id = client_id.to_string();
1046        let now = now_unix();
1047        self.with_conn(move |conn| {
1048            conn.execute(
1049                "INSERT INTO upstream_oauth_dynamic_clients (upstream_name, subject, client_id, created_at)
1050                 VALUES (?1, ?2, ?3, ?4)
1051                 ON CONFLICT(upstream_name, subject) DO UPDATE SET
1052                    client_id = excluded.client_id,
1053                    created_at = excluded.created_at",
1054                params![upstream_name, subject, client_id, now],
1055            )
1056            .map_err(sqlite_error)?;
1057            Ok(())
1058        })
1059        .await
1060    }
1061
1062    pub async fn find_dynamic_client_registration(
1063        &self,
1064        upstream_name: &str,
1065        subject: &str,
1066    ) -> Result<Option<String>, AuthError> {
1067        let upstream_name = upstream_name.to_string();
1068        let subject = subject.to_string();
1069        self.with_conn(move |conn| {
1070            conn.query_row(
1071                "SELECT client_id
1072                 FROM upstream_oauth_dynamic_clients
1073                 WHERE upstream_name = ?1 AND subject = ?2",
1074                params![upstream_name, subject],
1075                |row| row.get(0),
1076            )
1077            .optional()
1078            .map_err(sqlite_error)
1079        })
1080        .await
1081    }
1082
1083    pub async fn delete_dynamic_client_registration(
1084        &self,
1085        upstream_name: &str,
1086        subject: &str,
1087    ) -> Result<(), AuthError> {
1088        let upstream_name = upstream_name.to_string();
1089        let subject = subject.to_string();
1090        self.with_conn(move |conn| {
1091            conn.execute(
1092                "DELETE FROM upstream_oauth_dynamic_clients
1093                 WHERE upstream_name = ?1 AND subject = ?2",
1094                params![upstream_name, subject],
1095            )
1096            .map_err(sqlite_error)?;
1097            Ok(())
1098        })
1099        .await
1100    }
1101
1102    /// Add an email address to the allowlist.
1103    ///
1104    /// `email` is normalised to lowercase before storage. Returns
1105    /// `AuthError::Validation` if the email is already present.
1106    pub async fn add_allowed_user(
1107        &self,
1108        email: &str,
1109        added_by: &str,
1110        created_at: i64,
1111    ) -> Result<(), AuthError> {
1112        let email = email.to_lowercase();
1113        let fp = fingerprint(&email);
1114        let added_by = added_by.to_string();
1115        self.with_conn(move |conn| {
1116            let changed = conn
1117                .execute(
1118                    "INSERT INTO allowed_users (email, added_by, created_at)
1119                     VALUES (?1, ?2, ?3)",
1120                    params![email, added_by, created_at],
1121                )
1122                .map_err(|error| match error {
1123                    rusqlite::Error::SqliteFailure(ref e, _)
1124                        if e.code == rusqlite::ErrorCode::ConstraintViolation =>
1125                    {
1126                        AuthError::Validation(format!(
1127                            "email fingerprint {fp} is already in the allowlist"
1128                        ))
1129                    }
1130                    other => sqlite_error(other),
1131                })?;
1132            debug_assert_eq!(changed, 1);
1133            Ok(())
1134        })
1135        .await
1136    }
1137
1138    /// Remove an email address from the allowlist.
1139    ///
1140    /// Idempotent: returns `Ok(())` even if the email was not present.
1141    pub async fn remove_allowed_user(&self, email: &str) -> Result<(), AuthError> {
1142        let email = email.to_lowercase();
1143        self.with_conn(move |conn| {
1144            conn.execute("DELETE FROM allowed_users WHERE email = ?1", params![email])
1145                .map_err(sqlite_error)?;
1146            Ok(())
1147        })
1148        .await
1149    }
1150
1151    /// Return all allowlist rows ordered by `created_at ASC`.
1152    pub async fn list_allowed_users(&self) -> Result<Vec<AllowedUserRow>, AuthError> {
1153        self.with_conn(move |conn| {
1154            let mut stmt = conn
1155                .prepare(
1156                    "SELECT email, added_by, created_at
1157                     FROM allowed_users
1158                     ORDER BY created_at ASC",
1159                )
1160                .map_err(sqlite_error)?;
1161            let rows = stmt
1162                .query_map([], row_to_allowed_user)
1163                .map_err(sqlite_error)?
1164                .collect::<rusqlite::Result<Vec<_>>>()
1165                .map_err(sqlite_error)?;
1166            Ok(rows)
1167        })
1168        .await
1169    }
1170
1171    async fn with_conn<T, F>(&self, op: F) -> Result<T, AuthError>
1172    where
1173        T: Send + 'static,
1174        F: FnOnce(&Connection) -> Result<T, AuthError> + Send + 'static,
1175    {
1176        let conns = Arc::clone(&self.conns);
1177        let path = Arc::clone(&self.path);
1178        let len = conns.len();
1179        let idx = self.next_conn.fetch_add(1, Ordering::Relaxed) % len;
1180        tokio::task::spawn_blocking(move || {
1181            let mut guard = conns[idx]
1182                .lock()
1183                .map_err(|_| AuthError::Storage("sqlite mutex poisoned".to_string()))?;
1184            validate_or_reopen_connection(&mut guard, path.as_ref())?;
1185            op(&guard)
1186        })
1187        .await
1188        .map_err(|error| AuthError::Storage(format!("sqlite task failed: {error}")))?
1189    }
1190
1191    #[cfg(test)]
1192    fn connection_count(&self) -> usize {
1193        self.conns.len()
1194    }
1195}
1196
1197fn open_connections(path: &Path, count: usize) -> Result<Vec<Connection>, AuthError> {
1198    (0..count).map(|_| open_connection(path)).collect()
1199}
1200
1201#[allow(clippy::too_many_lines)]
1202fn open_connection(path: &Path) -> Result<Connection, AuthError> {
1203    if let Some(parent) = path.parent() {
1204        std::fs::create_dir_all(parent).map_err(|error| {
1205            AuthError::Storage(format!(
1206                "create auth database directory `{}`: {error}",
1207                parent.display()
1208            ))
1209        })?;
1210    }
1211
1212    let existed = path.exists();
1213    if existed {
1214        ensure_restrictive_permissions(path)?;
1215    }
1216
1217    let conn = Connection::open(path).map_err(sqlite_error)?;
1218    conn.busy_timeout(std::time::Duration::from_millis(SQLITE_BUSY_TIMEOUT_MS))
1219        .map_err(sqlite_error)?;
1220    conn.pragma_update(None, "journal_mode", "WAL")
1221        .map_err(sqlite_error)?;
1222    conn.pragma_update(None, "foreign_keys", "ON")
1223        .map_err(sqlite_error)?;
1224    conn.execute_batch(
1225        "CREATE TABLE IF NOT EXISTS registered_clients (
1226            client_id TEXT PRIMARY KEY,
1227            redirect_uris TEXT NOT NULL,
1228            created_at INTEGER NOT NULL
1229        );
1230        CREATE TABLE IF NOT EXISTS authorization_requests (
1231            state TEXT PRIMARY KEY,
1232            client_id TEXT NOT NULL,
1233            redirect_uri TEXT NOT NULL,
1234            client_state TEXT NOT NULL,
1235            resource TEXT NOT NULL DEFAULT '',
1236            scope TEXT NOT NULL,
1237            provider TEXT NOT NULL DEFAULT 'google',
1238            provider_code_verifier TEXT NOT NULL,
1239            code_challenge TEXT NOT NULL,
1240            code_challenge_method TEXT NOT NULL,
1241            created_at INTEGER NOT NULL,
1242            expires_at INTEGER NOT NULL
1243        );
1244        CREATE TABLE IF NOT EXISTS authorization_codes (
1245            code TEXT PRIMARY KEY,
1246            client_id TEXT NOT NULL,
1247            subject TEXT NOT NULL,
1248            redirect_uri TEXT NOT NULL,
1249            resource TEXT NOT NULL DEFAULT '',
1250            scope TEXT NOT NULL,
1251            provider TEXT NOT NULL DEFAULT 'google',
1252            code_challenge TEXT NOT NULL,
1253            code_challenge_method TEXT NOT NULL,
1254            provider_refresh_token TEXT,
1255            created_at INTEGER NOT NULL,
1256            expires_at INTEGER NOT NULL
1257        );
1258        CREATE TABLE IF NOT EXISTS refresh_tokens (
1259            refresh_token_hash TEXT PRIMARY KEY,
1260            client_id TEXT NOT NULL,
1261            subject TEXT NOT NULL,
1262            resource TEXT NOT NULL DEFAULT '',
1263            scope TEXT NOT NULL,
1264            provider TEXT NOT NULL DEFAULT 'google',
1265            provider_refresh_token TEXT,
1266            created_at INTEGER NOT NULL,
1267            expires_at INTEGER NOT NULL
1268        );
1269        CREATE TABLE IF NOT EXISTS browser_sessions (
1270            session_id TEXT PRIMARY KEY,
1271            subject TEXT NOT NULL,
1272            email TEXT,
1273            csrf_token TEXT NOT NULL,
1274            created_at INTEGER NOT NULL,
1275            expires_at INTEGER NOT NULL
1276        );
1277        CREATE TABLE IF NOT EXISTS browser_login_states (
1278            state TEXT PRIMARY KEY,
1279            return_to TEXT NOT NULL,
1280            provider TEXT NOT NULL DEFAULT 'google',
1281            provider_code_verifier TEXT NOT NULL,
1282            created_at INTEGER NOT NULL,
1283            expires_at INTEGER NOT NULL
1284        );
1285        CREATE TABLE IF NOT EXISTS native_authorization_results (
1286            state TEXT PRIMARY KEY,
1287            code TEXT NOT NULL,
1288            created_at INTEGER NOT NULL,
1289            expires_at INTEGER NOT NULL
1290        );
1291        CREATE TABLE IF NOT EXISTS upstream_oauth_credentials (
1292            upstream_name             TEXT NOT NULL,
1293            subject                   TEXT NOT NULL,
1294            client_id                 TEXT NOT NULL,
1295            granted_scopes_json       TEXT NOT NULL,
1296            token_blob                BLOB NOT NULL,
1297            token_blob_nonce          BLOB NOT NULL,
1298            token_received_at         INTEGER NOT NULL,
1299            access_token_expires_at   INTEGER NOT NULL,
1300            refresh_token_present     INTEGER NOT NULL,
1301            PRIMARY KEY (upstream_name, subject)
1302        ) WITHOUT ROWID;
1303        CREATE TABLE IF NOT EXISTS upstream_oauth_state (
1304            upstream_name   TEXT NOT NULL,
1305            subject         TEXT NOT NULL,
1306            csrf_token      TEXT NOT NULL,
1307            pkce_verifier   TEXT NOT NULL,
1308            created_at      INTEGER NOT NULL,
1309            expires_at      INTEGER NOT NULL,
1310            PRIMARY KEY (upstream_name, subject, csrf_token)
1311        ) WITHOUT ROWID;
1312        CREATE TABLE IF NOT EXISTS upstream_oauth_dynamic_clients (
1313            upstream_name   TEXT NOT NULL,
1314            subject         TEXT NOT NULL,
1315            client_id       TEXT NOT NULL,
1316            created_at      INTEGER NOT NULL,
1317            PRIMARY KEY (upstream_name, subject)
1318        ) WITHOUT ROWID;
1319        CREATE TABLE IF NOT EXISTS allowed_users (
1320            email       TEXT PRIMARY KEY NOT NULL,
1321            added_by    TEXT NOT NULL,
1322            created_at  INTEGER NOT NULL
1323        );",
1324    )
1325    .map_err(sqlite_error)?;
1326    add_column_if_missing(
1327        &conn,
1328        "authorization_requests",
1329        "resource",
1330        "TEXT NOT NULL DEFAULT ''",
1331    )?;
1332    add_column_if_missing(
1333        &conn,
1334        "authorization_codes",
1335        "resource",
1336        "TEXT NOT NULL DEFAULT ''",
1337    )?;
1338    add_column_if_missing(
1339        &conn,
1340        "refresh_tokens",
1341        "resource",
1342        "TEXT NOT NULL DEFAULT ''",
1343    )?;
1344    add_column_if_missing(
1345        &conn,
1346        "authorization_requests",
1347        "provider",
1348        "TEXT NOT NULL DEFAULT 'google'",
1349    )?;
1350    add_column_if_missing(
1351        &conn,
1352        "authorization_codes",
1353        "provider",
1354        "TEXT NOT NULL DEFAULT 'google'",
1355    )?;
1356    add_column_if_missing(
1357        &conn,
1358        "refresh_tokens",
1359        "provider",
1360        "TEXT NOT NULL DEFAULT 'google'",
1361    )?;
1362    add_column_if_missing(
1363        &conn,
1364        "browser_login_states",
1365        "provider",
1366        "TEXT NOT NULL DEFAULT 'google'",
1367    )?;
1368
1369    if !existed {
1370        set_restrictive_permissions(path)?;
1371    }
1372    ensure_restrictive_permissions(path)?;
1373
1374    run_migrations(&conn)?;
1375
1376    Ok(conn)
1377}
1378
1379/// One-time migrations keyed by `PRAGMA user_version`.
1380///
1381/// Migration 0 → 1: add `refresh_token_hash` to the `refresh_tokens` table
1382/// (if the table was created with the old `refresh_token TEXT PRIMARY KEY`
1383/// schema) and backfill SHA-256 hashes for any plaintext rows that pre-date
1384/// this change.  New databases created with the v1 schema already have
1385/// `refresh_token_hash` as the PK, so the `ALTER TABLE` step is a no-op in
1386/// that case.
1387fn run_migrations(conn: &Connection) -> Result<(), AuthError> {
1388    let current_version: i64 = conn
1389        .query_row("PRAGMA user_version;", [], |row| row.get(0))
1390        .map_err(sqlite_error)?;
1391
1392    if current_version < 1 {
1393        // Step 1: add `refresh_token_hash` column if missing (pre-v1 DBs have
1394        // `refresh_token TEXT PRIMARY KEY` and no hash column).
1395        let cols: Vec<String> = {
1396            let mut stmt = conn
1397                .prepare("PRAGMA table_info(refresh_tokens);")
1398                .map_err(sqlite_error)?;
1399            stmt.query_map([], |row| row.get::<_, String>(1))
1400                .map_err(sqlite_error)?
1401                .collect::<rusqlite::Result<Vec<_>>>()
1402                .map_err(sqlite_error)?
1403        };
1404
1405        if !cols.iter().any(|c| c == "refresh_token_hash") {
1406            // Old schema: add the column and back-fill SHA-256 hashes.
1407            conn.execute_batch("ALTER TABLE refresh_tokens ADD COLUMN refresh_token_hash TEXT;")
1408                .map_err(sqlite_error)?;
1409
1410            // Back-fill: hash existing plaintext `refresh_token` values.  We
1411            // can only do this in a SQL-only migration when the hash is
1412            // computed outside SQLite; instead load all rows, compute hashes
1413            // in Rust, and update.
1414            let rows: Vec<(String,)> = {
1415                let mut stmt = conn
1416                    .prepare("SELECT refresh_token FROM refresh_tokens WHERE refresh_token_hash IS NULL;")
1417                    .map_err(sqlite_error)?;
1418                stmt.query_map([], |row| Ok((row.get::<_, String>(0)?,)))
1419                    .map_err(sqlite_error)?
1420                    .collect::<rusqlite::Result<Vec<_>>>()
1421                    .map_err(sqlite_error)?
1422            };
1423            for (plaintext,) in rows {
1424                let hash = hash_token(&plaintext);
1425                conn.execute(
1426                    "UPDATE refresh_tokens SET refresh_token_hash = ?1 WHERE refresh_token = ?2 AND refresh_token_hash IS NULL;",
1427                    params![hash, plaintext],
1428                )
1429                .map_err(sqlite_error)?;
1430            }
1431
1432            warn!(
1433                "migration v1: added refresh_token_hash column and backfilled existing rows — old plaintext tokens invalidated on next rotation"
1434            );
1435        }
1436
1437        // Ensure a UNIQUE index exists on refresh_token_hash so that
1438        // ON CONFLICT(refresh_token_hash) works correctly on pre-existing
1439        // databases where the column was added by ALTER TABLE (not declared as
1440        // PRIMARY KEY).  On new databases the column is already PRIMARY KEY so
1441        // this index is redundant but harmless.
1442        conn.execute_batch(
1443            "CREATE UNIQUE INDEX IF NOT EXISTS idx_refresh_tokens_hash \
1444             ON refresh_tokens(refresh_token_hash);",
1445        )
1446        .map_err(sqlite_error)?;
1447
1448        conn.execute_batch("PRAGMA user_version = 1;")
1449            .map_err(sqlite_error)?;
1450    }
1451
1452    if current_version < 2 {
1453        // Step 2: add `dynamic_client_id` column to `upstream_oauth_state`.
1454        // This column binds the OAuth client_id used to begin a specific
1455        // authorization flow to the CSRF state row so that concurrent
1456        // `begin_authorization` calls for the same upstream+subject can each
1457        // complete their own callback with the correct client_id (lab-77y5.15).
1458        add_column_if_missing(conn, "upstream_oauth_state", "dynamic_client_id", "TEXT")?;
1459
1460        conn.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION};"))
1461            .map_err(sqlite_error)?;
1462    }
1463
1464    Ok(())
1465}
1466
1467/// Compute a hex-encoded SHA-256 digest of a token for safe storage.
1468///
1469/// The raw token (24+ bytes of random entropy) has sufficient pre-image
1470/// resistance for SHA-256 to be appropriate here — Argon2 would add
1471/// per-request latency without a meaningful security benefit.
1472/// AAD binding an encrypted `provider_refresh_token` to its row identity.
1473///
1474/// The refresh-token hash is the table's primary key and is derivable at
1475/// both encrypt time (upsert/rotate hash the plaintext token before insert)
1476/// and decrypt time (lookup is keyed by the same hash), so a ciphertext
1477/// copied onto a row with a different hash fails authentication.  Mirrors
1478/// the `key=value` AAD shape used by `upstream::store::credential_aad`.
1479fn refresh_token_aad(refresh_token_hash: &str) -> Vec<u8> {
1480    format!("refresh_token_hash={refresh_token_hash}").into_bytes()
1481}
1482
1483fn hash_token(token: &str) -> String {
1484    let digest = Sha256::digest(token.as_bytes());
1485    let mut hex = String::with_capacity(64);
1486    for byte in &digest {
1487        let _ = write!(&mut hex, "{byte:02x}");
1488    }
1489    hex
1490}
1491
1492fn validate_or_reopen_connection(conn: &mut Connection, path: &Path) -> Result<(), AuthError> {
1493    let Err(error) = conn.query_row("SELECT 1", [], |row| row.get::<_, i64>(0)) else {
1494        return Ok(());
1495    };
1496    warn!(
1497        path = %path.display(),
1498        error = %error,
1499        "stale sqlite connection detected, reopening"
1500    );
1501
1502    *conn = open_connection(path)?;
1503    conn.query_row("SELECT 1", [], |row| row.get::<_, i64>(0))
1504        .map(|_| ())
1505        .map_err(sqlite_error)
1506}
1507
1508#[allow(clippy::needless_pass_by_value)]
1509fn sqlite_error(error: rusqlite::Error) -> AuthError {
1510    AuthError::Storage(format!("sqlite error: {error}"))
1511}
1512
1513fn add_column_if_missing(
1514    conn: &Connection,
1515    table: &str,
1516    column: &str,
1517    definition: &str,
1518) -> Result<(), AuthError> {
1519    let mut stmt = conn
1520        .prepare(&format!("PRAGMA table_info({table})"))
1521        .map_err(sqlite_error)?;
1522    let exists = stmt
1523        .query_map([], |row| row.get::<_, String>(1))
1524        .map_err(sqlite_error)?
1525        .collect::<rusqlite::Result<Vec<_>>>()
1526        .map_err(sqlite_error)?
1527        .iter()
1528        .any(|name| name == column);
1529    if !exists {
1530        conn.execute(
1531            &format!("ALTER TABLE {table} ADD COLUMN {column} {definition}"),
1532            [],
1533        )
1534        .map_err(sqlite_error)?;
1535    }
1536    Ok(())
1537}
1538
1539#[cfg(test)]
1540#[path = "sqlite_tests.rs"]
1541mod tests;
1542
1543#[cfg(test)]
1544#[path = "sqlite_migration_tests.rs"]
1545mod migration_tests;