Skip to main content

assay_auth/oidc_provider/
store.rs

1//! Storage traits + PG/SQLite implementations for the OIDC provider.
2//!
3//! Two trait families:
4//!
5//! - [`OidcClientStore`] — CRUD over `auth.oidc_clients`.
6//! - [`OidcUpstreamStore`] — CRUD over `auth.upstream_providers`.
7//!
8//! Plus the concrete row stores:
9//!
10//! - [`OidcCodeStore`] — issue / consume `auth.oidc_authorization_codes`.
11//! - [`OidcRefreshStore`] — write / verify / revoke
12//!   `auth.oidc_refresh_tokens`.
13//! - [`OidcSessionStore`] — `auth.oidc_sessions` lookups for the SSO
14//!   registry + back-channel logout fan-out.
15//! - [`OidcConsentStore`] — per-(user, client) consent grants.
16//! - [`OidcUpstreamStateStore`] — short-lived federation login state.
17//!
18//! All trait methods return `anyhow::Result<…>` so a backend can surface
19//! its native error verbatim. Handlers translate to [`crate::Error`] at
20//! the boundary.
21
22use std::collections::BTreeMap;
23use std::sync::Arc;
24
25use anyhow::{Context, Result};
26use async_trait::async_trait;
27
28use super::types::{
29    AuthorizationCode, ConsentGrant, OidcClient, OidcSession, RefreshToken, TokenAuthMethod,
30    UpstreamLoginState, UpstreamProvider,
31};
32
33#[async_trait]
34pub trait OidcClientStore: Send + Sync + 'static {
35    async fn create(&self, client: &OidcClient) -> Result<()>;
36    async fn get(&self, client_id: &str) -> Result<Option<OidcClient>>;
37    async fn list(&self) -> Result<Vec<OidcClient>>;
38    async fn update(&self, client: &OidcClient) -> Result<()>;
39    async fn delete(&self, client_id: &str) -> Result<bool>;
40    /// Replace the client_secret_hash. Returns Ok(false) if no row matched.
41    async fn rotate_secret_hash(&self, client_id: &str, new_hash: &str) -> Result<bool>;
42}
43
44#[async_trait]
45pub trait OidcUpstreamStore: Send + Sync + 'static {
46    async fn upsert(&self, provider: &UpstreamProvider) -> Result<()>;
47    async fn get(&self, slug: &str) -> Result<Option<UpstreamProvider>>;
48    async fn list(&self) -> Result<Vec<UpstreamProvider>>;
49    async fn delete(&self, slug: &str) -> Result<bool>;
50}
51
52#[async_trait]
53pub trait OidcCodeStore: Send + Sync + 'static {
54    async fn create(&self, code: &AuthorizationCode) -> Result<()>;
55    /// Atomic consume — UPDATE … WHERE consumed = FALSE. If 0 rows are
56    /// affected the code was either missing or already consumed; the
57    /// caller treats both as `invalid_grant`. Returns the row's pre-
58    /// consume snapshot when the consume succeeded.
59    async fn consume(&self, code: &str) -> Result<Option<AuthorizationCode>>;
60}
61
62#[async_trait]
63pub trait OidcRefreshStore: Send + Sync + 'static {
64    async fn create(&self, token: &RefreshToken) -> Result<()>;
65    async fn get(&self, token_hash: &str) -> Result<Option<RefreshToken>>;
66    async fn revoke(&self, token_hash: &str) -> Result<bool>;
67    /// Revoke every refresh token belonging to `user_id` — the replay-
68    /// detection nuke per OAuth 2.1.
69    async fn revoke_for_user(&self, user_id: &str) -> Result<u64>;
70}
71
72#[async_trait]
73pub trait OidcSessionStore: Send + Sync + 'static {
74    async fn create(&self, session: &OidcSession) -> Result<()>;
75    async fn get(&self, sid: &str) -> Result<Option<OidcSession>>;
76    /// Every SSO session row tied to a single assay session — used by
77    /// `/logout` to fan out back-channel logout.
78    async fn list_by_assay_session(&self, assay_session_id: &str) -> Result<Vec<OidcSession>>;
79    async fn delete(&self, sid: &str) -> Result<bool>;
80    async fn delete_by_assay_session(&self, assay_session_id: &str) -> Result<u64>;
81}
82
83#[async_trait]
84pub trait OidcConsentStore: Send + Sync + 'static {
85    async fn upsert(&self, grant: &ConsentGrant) -> Result<()>;
86    async fn get(&self, user_id: &str, client_id: &str) -> Result<Option<ConsentGrant>>;
87    async fn delete(&self, user_id: &str, client_id: &str) -> Result<bool>;
88}
89
90#[async_trait]
91pub trait OidcUpstreamStateStore: Send + Sync + 'static {
92    async fn create(&self, state: &UpstreamLoginState) -> Result<()>;
93    /// Atomically delete and return — single use.
94    async fn take(&self, state: &str) -> Result<Option<UpstreamLoginState>>;
95}
96
97// =====================================================================
98//   POSTGRES
99// =====================================================================
100
101#[cfg(feature = "backend-postgres")]
102mod pg {
103    use super::*;
104    use sqlx::{PgPool, Row};
105
106    fn parse_json_array(s: &str) -> Vec<String> {
107        serde_json::from_str(s).unwrap_or_default()
108    }
109
110    fn encode_json_array(v: &[String]) -> String {
111        serde_json::to_string(v).unwrap_or_else(|_| "[]".to_string())
112    }
113
114    fn map_client_row(row: sqlx::postgres::PgRow) -> OidcClient {
115        let auth_method: String = row.get("token_endpoint_auth_method");
116        OidcClient {
117            client_id: row.get("client_id"),
118            client_secret_hash: row.get("client_secret_hash"),
119            redirect_uris: parse_json_array(&row.get::<String, _>("redirect_uris")),
120            name: row.get("name"),
121            logo_url: row.get("logo_url"),
122            token_endpoint_auth_method: TokenAuthMethod::parse(&auth_method)
123                .unwrap_or(TokenAuthMethod::ClientSecretBasic),
124            default_scopes: parse_json_array(&row.get::<String, _>("default_scopes")),
125            require_consent: row.get("require_consent"),
126            grant_types: parse_json_array(&row.get::<String, _>("grant_types")),
127            response_types: parse_json_array(&row.get::<String, _>("response_types")),
128            pkce_required: row.get("pkce_required"),
129            backchannel_logout_uri: row.get("backchannel_logout_uri"),
130            created_at: row.get("created_at"),
131        }
132    }
133
134    /// Postgres-backed [`OidcClientStore`].
135    #[derive(Clone)]
136    pub struct PostgresOidcClientStore {
137        pool: PgPool,
138    }
139
140    impl PostgresOidcClientStore {
141        pub fn new(pool: PgPool) -> Self {
142            Self { pool }
143        }
144        pub fn into_dyn(self) -> Arc<dyn OidcClientStore> {
145            Arc::new(self)
146        }
147    }
148
149    #[async_trait]
150    impl OidcClientStore for PostgresOidcClientStore {
151        async fn create(&self, c: &OidcClient) -> Result<()> {
152            sqlx::query(
153                "INSERT INTO auth.oidc_clients
154                    (client_id, client_secret_hash, redirect_uris, name, logo_url,
155                     token_endpoint_auth_method, default_scopes, require_consent,
156                     grant_types, response_types, pkce_required,
157                     backchannel_logout_uri, created_at)
158                 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)",
159            )
160            .bind(&c.client_id)
161            .bind(&c.client_secret_hash)
162            .bind(encode_json_array(&c.redirect_uris))
163            .bind(&c.name)
164            .bind(&c.logo_url)
165            .bind(c.token_endpoint_auth_method.as_str())
166            .bind(encode_json_array(&c.default_scopes))
167            .bind(c.require_consent)
168            .bind(encode_json_array(&c.grant_types))
169            .bind(encode_json_array(&c.response_types))
170            .bind(c.pkce_required)
171            .bind(&c.backchannel_logout_uri)
172            .bind(c.created_at)
173            .execute(&self.pool)
174            .await
175            .context("auth.oidc_clients insert")?;
176            Ok(())
177        }
178
179        async fn get(&self, client_id: &str) -> Result<Option<OidcClient>> {
180            let row = sqlx::query("SELECT * FROM auth.oidc_clients WHERE client_id = $1")
181                .bind(client_id)
182                .fetch_optional(&self.pool)
183                .await
184                .context("auth.oidc_clients select")?;
185            Ok(row.map(map_client_row))
186        }
187
188        async fn list(&self) -> Result<Vec<OidcClient>> {
189            let rows = sqlx::query("SELECT * FROM auth.oidc_clients ORDER BY created_at")
190                .fetch_all(&self.pool)
191                .await
192                .context("auth.oidc_clients list")?;
193            Ok(rows.into_iter().map(map_client_row).collect())
194        }
195
196        async fn update(&self, c: &OidcClient) -> Result<()> {
197            sqlx::query(
198                "UPDATE auth.oidc_clients SET
199                    client_secret_hash = $2,
200                    redirect_uris = $3,
201                    name = $4,
202                    logo_url = $5,
203                    token_endpoint_auth_method = $6,
204                    default_scopes = $7,
205                    require_consent = $8,
206                    grant_types = $9,
207                    response_types = $10,
208                    pkce_required = $11,
209                    backchannel_logout_uri = $12
210                 WHERE client_id = $1",
211            )
212            .bind(&c.client_id)
213            .bind(&c.client_secret_hash)
214            .bind(encode_json_array(&c.redirect_uris))
215            .bind(&c.name)
216            .bind(&c.logo_url)
217            .bind(c.token_endpoint_auth_method.as_str())
218            .bind(encode_json_array(&c.default_scopes))
219            .bind(c.require_consent)
220            .bind(encode_json_array(&c.grant_types))
221            .bind(encode_json_array(&c.response_types))
222            .bind(c.pkce_required)
223            .bind(&c.backchannel_logout_uri)
224            .execute(&self.pool)
225            .await
226            .context("auth.oidc_clients update")?;
227            Ok(())
228        }
229
230        async fn delete(&self, client_id: &str) -> Result<bool> {
231            let r = sqlx::query("DELETE FROM auth.oidc_clients WHERE client_id = $1")
232                .bind(client_id)
233                .execute(&self.pool)
234                .await
235                .context("auth.oidc_clients delete")?;
236            Ok(r.rows_affected() > 0)
237        }
238
239        async fn rotate_secret_hash(&self, client_id: &str, new_hash: &str) -> Result<bool> {
240            let r = sqlx::query(
241                "UPDATE auth.oidc_clients SET client_secret_hash = $2 WHERE client_id = $1",
242            )
243            .bind(client_id)
244            .bind(new_hash)
245            .execute(&self.pool)
246            .await
247            .context("auth.oidc_clients rotate_secret_hash")?;
248            Ok(r.rows_affected() > 0)
249        }
250    }
251
252    fn map_upstream_row(row: sqlx::postgres::PgRow) -> UpstreamProvider {
253        let scopes_str: String = row.try_get("scopes").unwrap_or_default();
254        let auth_params_str: String = row.try_get("auth_params").unwrap_or_default();
255        UpstreamProvider {
256            slug: row.get("slug"),
257            issuer: row.get("issuer"),
258            client_id: row.get("client_id"),
259            client_secret: row.get("client_secret"),
260            display_name: row.get("display_name"),
261            icon_url: row.get("icon_url"),
262            enabled: row.get("enabled"),
263            scopes: parse_json_array(&scopes_str),
264            auth_params: parse_auth_params(&auth_params_str),
265        }
266    }
267
268    fn parse_auth_params(s: &str) -> BTreeMap<String, String> {
269        if s.is_empty() {
270            return BTreeMap::new();
271        }
272        serde_json::from_str(s).unwrap_or_default()
273    }
274
275    fn encode_auth_params(map: &BTreeMap<String, String>) -> String {
276        serde_json::to_string(map).unwrap_or_else(|_| "{}".to_string())
277    }
278
279    #[derive(Clone)]
280    pub struct PostgresOidcUpstreamStore {
281        pool: PgPool,
282    }
283
284    impl PostgresOidcUpstreamStore {
285        pub fn new(pool: PgPool) -> Self {
286            Self { pool }
287        }
288        pub fn into_dyn(self) -> Arc<dyn OidcUpstreamStore> {
289            Arc::new(self)
290        }
291    }
292
293    #[async_trait]
294    impl OidcUpstreamStore for PostgresOidcUpstreamStore {
295        async fn upsert(&self, p: &UpstreamProvider) -> Result<()> {
296            sqlx::query(
297                "INSERT INTO auth.upstream_providers
298                    (slug, issuer, client_id, client_secret, display_name, icon_url,
299                     enabled, scopes, auth_params)
300                 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
301                 ON CONFLICT (slug) DO UPDATE SET
302                    issuer = EXCLUDED.issuer,
303                    client_id = EXCLUDED.client_id,
304                    client_secret = EXCLUDED.client_secret,
305                    display_name = EXCLUDED.display_name,
306                    icon_url = EXCLUDED.icon_url,
307                    enabled = EXCLUDED.enabled,
308                    scopes = EXCLUDED.scopes,
309                    auth_params = EXCLUDED.auth_params",
310            )
311            .bind(&p.slug)
312            .bind(&p.issuer)
313            .bind(&p.client_id)
314            .bind(&p.client_secret)
315            .bind(&p.display_name)
316            .bind(&p.icon_url)
317            .bind(p.enabled)
318            .bind(encode_json_array(&p.scopes))
319            .bind(encode_auth_params(&p.auth_params))
320            .execute(&self.pool)
321            .await
322            .context("auth.upstream_providers upsert")?;
323            Ok(())
324        }
325
326        async fn get(&self, slug: &str) -> Result<Option<UpstreamProvider>> {
327            let row = sqlx::query("SELECT * FROM auth.upstream_providers WHERE slug = $1")
328                .bind(slug)
329                .fetch_optional(&self.pool)
330                .await
331                .context("auth.upstream_providers select")?;
332            Ok(row.map(map_upstream_row))
333        }
334
335        async fn list(&self) -> Result<Vec<UpstreamProvider>> {
336            let rows = sqlx::query("SELECT * FROM auth.upstream_providers ORDER BY slug")
337                .fetch_all(&self.pool)
338                .await
339                .context("auth.upstream_providers list")?;
340            Ok(rows.into_iter().map(map_upstream_row).collect())
341        }
342
343        async fn delete(&self, slug: &str) -> Result<bool> {
344            let r = sqlx::query("DELETE FROM auth.upstream_providers WHERE slug = $1")
345                .bind(slug)
346                .execute(&self.pool)
347                .await
348                .context("auth.upstream_providers delete")?;
349            Ok(r.rows_affected() > 0)
350        }
351    }
352
353    fn map_code_row(row: sqlx::postgres::PgRow) -> AuthorizationCode {
354        AuthorizationCode {
355            code: row.get("code"),
356            client_id: row.get("client_id"),
357            user_id: row.get("user_id"),
358            redirect_uri: row.get("redirect_uri"),
359            scopes: parse_json_array(&row.get::<String, _>("scopes")),
360            code_challenge: row.get("code_challenge"),
361            code_challenge_method: row.get("code_challenge_method"),
362            nonce: row.get("nonce"),
363            state: row.get("state"),
364            issued_at: row.get("issued_at"),
365            expires_at: row.get("expires_at"),
366            consumed: row.get("consumed"),
367        }
368    }
369
370    #[derive(Clone)]
371    pub struct PostgresOidcCodeStore {
372        pool: PgPool,
373    }
374    impl PostgresOidcCodeStore {
375        pub fn new(pool: PgPool) -> Self {
376            Self { pool }
377        }
378        pub fn into_dyn(self) -> Arc<dyn OidcCodeStore> {
379            Arc::new(self)
380        }
381    }
382
383    #[async_trait]
384    impl OidcCodeStore for PostgresOidcCodeStore {
385        async fn create(&self, c: &AuthorizationCode) -> Result<()> {
386            sqlx::query(
387                "INSERT INTO auth.oidc_authorization_codes
388                    (code, client_id, user_id, redirect_uri, scopes,
389                     code_challenge, code_challenge_method, nonce, state,
390                     issued_at, expires_at, consumed)
391                 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)",
392            )
393            .bind(&c.code)
394            .bind(&c.client_id)
395            .bind(&c.user_id)
396            .bind(&c.redirect_uri)
397            .bind(encode_json_array(&c.scopes))
398            .bind(&c.code_challenge)
399            .bind(&c.code_challenge_method)
400            .bind(&c.nonce)
401            .bind(&c.state)
402            .bind(c.issued_at)
403            .bind(c.expires_at)
404            .bind(c.consumed)
405            .execute(&self.pool)
406            .await
407            .context("auth.oidc_authorization_codes insert")?;
408            Ok(())
409        }
410
411        async fn consume(&self, code: &str) -> Result<Option<AuthorizationCode>> {
412            // RETURNING semantics — fetch the row at the same time we
413            // mark it consumed. The `consumed = FALSE` predicate is the
414            // single-use guarantee.
415            let row = sqlx::query(
416                "UPDATE auth.oidc_authorization_codes
417                    SET consumed = TRUE
418                    WHERE code = $1 AND consumed = FALSE
419                    RETURNING *",
420            )
421            .bind(code)
422            .fetch_optional(&self.pool)
423            .await
424            .context("auth.oidc_authorization_codes consume")?;
425            Ok(row.map(map_code_row))
426        }
427    }
428
429    fn map_refresh_row(row: sqlx::postgres::PgRow) -> RefreshToken {
430        RefreshToken {
431            token_hash: row.get("token_hash"),
432            client_id: row.get("client_id"),
433            user_id: row.get("user_id"),
434            scopes: parse_json_array(&row.get::<String, _>("scopes")),
435            issued_at: row.get("issued_at"),
436            expires_at: row.get("expires_at"),
437            revoked: row.get("revoked"),
438        }
439    }
440
441    #[derive(Clone)]
442    pub struct PostgresOidcRefreshStore {
443        pool: PgPool,
444    }
445    impl PostgresOidcRefreshStore {
446        pub fn new(pool: PgPool) -> Self {
447            Self { pool }
448        }
449        pub fn into_dyn(self) -> Arc<dyn OidcRefreshStore> {
450            Arc::new(self)
451        }
452    }
453
454    #[async_trait]
455    impl OidcRefreshStore for PostgresOidcRefreshStore {
456        async fn create(&self, t: &RefreshToken) -> Result<()> {
457            sqlx::query(
458                "INSERT INTO auth.oidc_refresh_tokens
459                    (token_hash, client_id, user_id, scopes,
460                     issued_at, expires_at, revoked)
461                 VALUES ($1, $2, $3, $4, $5, $6, $7)",
462            )
463            .bind(&t.token_hash)
464            .bind(&t.client_id)
465            .bind(&t.user_id)
466            .bind(encode_json_array(&t.scopes))
467            .bind(t.issued_at)
468            .bind(t.expires_at)
469            .bind(t.revoked)
470            .execute(&self.pool)
471            .await
472            .context("auth.oidc_refresh_tokens insert")?;
473            Ok(())
474        }
475
476        async fn get(&self, token_hash: &str) -> Result<Option<RefreshToken>> {
477            let row = sqlx::query("SELECT * FROM auth.oidc_refresh_tokens WHERE token_hash = $1")
478                .bind(token_hash)
479                .fetch_optional(&self.pool)
480                .await
481                .context("auth.oidc_refresh_tokens select")?;
482            Ok(row.map(map_refresh_row))
483        }
484
485        async fn revoke(&self, token_hash: &str) -> Result<bool> {
486            let r = sqlx::query(
487                "UPDATE auth.oidc_refresh_tokens SET revoked = TRUE WHERE token_hash = $1",
488            )
489            .bind(token_hash)
490            .execute(&self.pool)
491            .await
492            .context("auth.oidc_refresh_tokens revoke")?;
493            Ok(r.rows_affected() > 0)
494        }
495
496        async fn revoke_for_user(&self, user_id: &str) -> Result<u64> {
497            let r = sqlx::query(
498                "UPDATE auth.oidc_refresh_tokens SET revoked = TRUE WHERE user_id = $1",
499            )
500            .bind(user_id)
501            .execute(&self.pool)
502            .await
503            .context("auth.oidc_refresh_tokens revoke_for_user")?;
504            Ok(r.rows_affected())
505        }
506    }
507
508    fn map_session_row(row: sqlx::postgres::PgRow) -> OidcSession {
509        OidcSession {
510            sid: row.get("sid"),
511            user_id: row.get("user_id"),
512            client_id: row.get("client_id"),
513            assay_session_id: row.get("assay_session_id"),
514            issued_at: row.get("issued_at"),
515            backchannel_logout_uri: row.get("backchannel_logout_uri"),
516        }
517    }
518
519    #[derive(Clone)]
520    pub struct PostgresOidcSessionStore {
521        pool: PgPool,
522    }
523    impl PostgresOidcSessionStore {
524        pub fn new(pool: PgPool) -> Self {
525            Self { pool }
526        }
527        pub fn into_dyn(self) -> Arc<dyn OidcSessionStore> {
528            Arc::new(self)
529        }
530    }
531
532    #[async_trait]
533    impl OidcSessionStore for PostgresOidcSessionStore {
534        async fn create(&self, s: &OidcSession) -> Result<()> {
535            sqlx::query(
536                "INSERT INTO auth.oidc_sessions
537                    (sid, user_id, client_id, assay_session_id, issued_at, backchannel_logout_uri)
538                 VALUES ($1, $2, $3, $4, $5, $6)",
539            )
540            .bind(&s.sid)
541            .bind(&s.user_id)
542            .bind(&s.client_id)
543            .bind(&s.assay_session_id)
544            .bind(s.issued_at)
545            .bind(&s.backchannel_logout_uri)
546            .execute(&self.pool)
547            .await
548            .context("auth.oidc_sessions insert")?;
549            Ok(())
550        }
551
552        async fn get(&self, sid: &str) -> Result<Option<OidcSession>> {
553            let row = sqlx::query("SELECT * FROM auth.oidc_sessions WHERE sid = $1")
554                .bind(sid)
555                .fetch_optional(&self.pool)
556                .await
557                .context("auth.oidc_sessions get")?;
558            Ok(row.map(map_session_row))
559        }
560
561        async fn list_by_assay_session(&self, assay_session_id: &str) -> Result<Vec<OidcSession>> {
562            let rows = sqlx::query("SELECT * FROM auth.oidc_sessions WHERE assay_session_id = $1")
563                .bind(assay_session_id)
564                .fetch_all(&self.pool)
565                .await
566                .context("auth.oidc_sessions list_by_assay_session")?;
567            Ok(rows.into_iter().map(map_session_row).collect())
568        }
569
570        async fn delete(&self, sid: &str) -> Result<bool> {
571            let r = sqlx::query("DELETE FROM auth.oidc_sessions WHERE sid = $1")
572                .bind(sid)
573                .execute(&self.pool)
574                .await
575                .context("auth.oidc_sessions delete")?;
576            Ok(r.rows_affected() > 0)
577        }
578
579        async fn delete_by_assay_session(&self, assay_session_id: &str) -> Result<u64> {
580            let r = sqlx::query("DELETE FROM auth.oidc_sessions WHERE assay_session_id = $1")
581                .bind(assay_session_id)
582                .execute(&self.pool)
583                .await
584                .context("auth.oidc_sessions delete_by_assay_session")?;
585            Ok(r.rows_affected())
586        }
587    }
588
589    fn map_consent_row(row: sqlx::postgres::PgRow) -> ConsentGrant {
590        ConsentGrant {
591            user_id: row.get("user_id"),
592            client_id: row.get("client_id"),
593            scopes: parse_json_array(&row.get::<String, _>("scopes")),
594            granted_at: row.get("granted_at"),
595        }
596    }
597
598    #[derive(Clone)]
599    pub struct PostgresOidcConsentStore {
600        pool: PgPool,
601    }
602    impl PostgresOidcConsentStore {
603        pub fn new(pool: PgPool) -> Self {
604            Self { pool }
605        }
606        pub fn into_dyn(self) -> Arc<dyn OidcConsentStore> {
607            Arc::new(self)
608        }
609    }
610
611    #[async_trait]
612    impl OidcConsentStore for PostgresOidcConsentStore {
613        async fn upsert(&self, g: &ConsentGrant) -> Result<()> {
614            sqlx::query(
615                "INSERT INTO auth.oidc_consents (user_id, client_id, scopes, granted_at)
616                 VALUES ($1, $2, $3, $4)
617                 ON CONFLICT (user_id, client_id) DO UPDATE
618                     SET scopes = EXCLUDED.scopes,
619                         granted_at = EXCLUDED.granted_at",
620            )
621            .bind(&g.user_id)
622            .bind(&g.client_id)
623            .bind(encode_json_array(&g.scopes))
624            .bind(g.granted_at)
625            .execute(&self.pool)
626            .await
627            .context("auth.oidc_consents upsert")?;
628            Ok(())
629        }
630
631        async fn get(&self, user_id: &str, client_id: &str) -> Result<Option<ConsentGrant>> {
632            let row = sqlx::query(
633                "SELECT * FROM auth.oidc_consents WHERE user_id = $1 AND client_id = $2",
634            )
635            .bind(user_id)
636            .bind(client_id)
637            .fetch_optional(&self.pool)
638            .await
639            .context("auth.oidc_consents get")?;
640            Ok(row.map(map_consent_row))
641        }
642
643        async fn delete(&self, user_id: &str, client_id: &str) -> Result<bool> {
644            let r =
645                sqlx::query("DELETE FROM auth.oidc_consents WHERE user_id = $1 AND client_id = $2")
646                    .bind(user_id)
647                    .bind(client_id)
648                    .execute(&self.pool)
649                    .await
650                    .context("auth.oidc_consents delete")?;
651            Ok(r.rows_affected() > 0)
652        }
653    }
654
655    fn map_upstream_state_row(row: sqlx::postgres::PgRow) -> UpstreamLoginState {
656        UpstreamLoginState {
657            state: row.get("state"),
658            provider_slug: row.get("provider_slug"),
659            nonce: row.get("nonce"),
660            pkce_verifier: row.get("pkce_verifier"),
661            return_to: row.get("return_to"),
662            created_at: row.get("created_at"),
663            expires_at: row.get("expires_at"),
664            binding_hash: row.try_get("binding_hash").unwrap_or_default(),
665        }
666    }
667
668    #[derive(Clone)]
669    pub struct PostgresOidcUpstreamStateStore {
670        pool: PgPool,
671    }
672    impl PostgresOidcUpstreamStateStore {
673        pub fn new(pool: PgPool) -> Self {
674            Self { pool }
675        }
676        pub fn into_dyn(self) -> Arc<dyn OidcUpstreamStateStore> {
677            Arc::new(self)
678        }
679    }
680
681    #[async_trait]
682    impl OidcUpstreamStateStore for PostgresOidcUpstreamStateStore {
683        async fn create(&self, s: &UpstreamLoginState) -> Result<()> {
684            sqlx::query(
685                "INSERT INTO auth.oidc_upstream_states
686                    (state, provider_slug, nonce, pkce_verifier, return_to,
687                     created_at, expires_at, binding_hash)
688                 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
689            )
690            .bind(&s.state)
691            .bind(&s.provider_slug)
692            .bind(&s.nonce)
693            .bind(&s.pkce_verifier)
694            .bind(&s.return_to)
695            .bind(s.created_at)
696            .bind(s.expires_at)
697            .bind(&s.binding_hash)
698            .execute(&self.pool)
699            .await
700            .context("auth.oidc_upstream_states insert")?;
701            Ok(())
702        }
703
704        async fn take(&self, state: &str) -> Result<Option<UpstreamLoginState>> {
705            // Atomic delete-and-return — single-use semantic.
706            let row =
707                sqlx::query("DELETE FROM auth.oidc_upstream_states WHERE state = $1 RETURNING *")
708                    .bind(state)
709                    .fetch_optional(&self.pool)
710                    .await
711                    .context("auth.oidc_upstream_states take")?;
712            Ok(row.map(map_upstream_state_row))
713        }
714    }
715}
716
717#[cfg(feature = "backend-postgres")]
718pub use pg::{
719    PostgresOidcClientStore, PostgresOidcCodeStore, PostgresOidcConsentStore,
720    PostgresOidcRefreshStore, PostgresOidcSessionStore, PostgresOidcUpstreamStateStore,
721    PostgresOidcUpstreamStore,
722};
723
724// =====================================================================
725//   SQLITE
726// =====================================================================
727
728#[cfg(feature = "backend-sqlite")]
729mod sqlite_impl {
730    use super::*;
731    use sqlx::{Row, SqlitePool};
732
733    fn parse_json_array(s: &str) -> Vec<String> {
734        serde_json::from_str(s).unwrap_or_default()
735    }
736    fn encode_json_array(v: &[String]) -> String {
737        serde_json::to_string(v).unwrap_or_else(|_| "[]".to_string())
738    }
739    fn b(v: bool) -> i64 {
740        if v { 1 } else { 0 }
741    }
742    fn ub(v: i64) -> bool {
743        v != 0
744    }
745
746    fn map_client_row(row: sqlx::sqlite::SqliteRow) -> OidcClient {
747        let auth_method: String = row.get("token_endpoint_auth_method");
748        OidcClient {
749            client_id: row.get("client_id"),
750            client_secret_hash: row.get("client_secret_hash"),
751            redirect_uris: parse_json_array(&row.get::<String, _>("redirect_uris")),
752            name: row.get("name"),
753            logo_url: row.get("logo_url"),
754            token_endpoint_auth_method: TokenAuthMethod::parse(&auth_method)
755                .unwrap_or(TokenAuthMethod::ClientSecretBasic),
756            default_scopes: parse_json_array(&row.get::<String, _>("default_scopes")),
757            require_consent: ub(row.get("require_consent")),
758            grant_types: parse_json_array(&row.get::<String, _>("grant_types")),
759            response_types: parse_json_array(&row.get::<String, _>("response_types")),
760            pkce_required: ub(row.get("pkce_required")),
761            backchannel_logout_uri: row.get("backchannel_logout_uri"),
762            created_at: row.get("created_at"),
763        }
764    }
765
766    #[derive(Clone)]
767    pub struct SqliteOidcClientStore {
768        pool: SqlitePool,
769    }
770    impl SqliteOidcClientStore {
771        pub fn new(pool: SqlitePool) -> Self {
772            Self { pool }
773        }
774        pub fn into_dyn(self) -> Arc<dyn OidcClientStore> {
775            Arc::new(self)
776        }
777    }
778
779    #[async_trait]
780    impl OidcClientStore for SqliteOidcClientStore {
781        async fn create(&self, c: &OidcClient) -> Result<()> {
782            sqlx::query(
783                "INSERT INTO auth.oidc_clients
784                    (client_id, client_secret_hash, redirect_uris, name, logo_url,
785                     token_endpoint_auth_method, default_scopes, require_consent,
786                     grant_types, response_types, pkce_required,
787                     backchannel_logout_uri, created_at)
788                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
789            )
790            .bind(&c.client_id)
791            .bind(&c.client_secret_hash)
792            .bind(encode_json_array(&c.redirect_uris))
793            .bind(&c.name)
794            .bind(&c.logo_url)
795            .bind(c.token_endpoint_auth_method.as_str())
796            .bind(encode_json_array(&c.default_scopes))
797            .bind(b(c.require_consent))
798            .bind(encode_json_array(&c.grant_types))
799            .bind(encode_json_array(&c.response_types))
800            .bind(b(c.pkce_required))
801            .bind(&c.backchannel_logout_uri)
802            .bind(c.created_at)
803            .execute(&self.pool)
804            .await
805            .context("auth.oidc_clients insert")?;
806            Ok(())
807        }
808
809        async fn get(&self, client_id: &str) -> Result<Option<OidcClient>> {
810            let row = sqlx::query("SELECT * FROM auth.oidc_clients WHERE client_id = ?")
811                .bind(client_id)
812                .fetch_optional(&self.pool)
813                .await
814                .context("auth.oidc_clients get")?;
815            Ok(row.map(map_client_row))
816        }
817
818        async fn list(&self) -> Result<Vec<OidcClient>> {
819            let rows = sqlx::query("SELECT * FROM auth.oidc_clients ORDER BY created_at")
820                .fetch_all(&self.pool)
821                .await
822                .context("auth.oidc_clients list")?;
823            Ok(rows.into_iter().map(map_client_row).collect())
824        }
825
826        async fn update(&self, c: &OidcClient) -> Result<()> {
827            sqlx::query(
828                "UPDATE auth.oidc_clients SET
829                    client_secret_hash = ?,
830                    redirect_uris = ?,
831                    name = ?,
832                    logo_url = ?,
833                    token_endpoint_auth_method = ?,
834                    default_scopes = ?,
835                    require_consent = ?,
836                    grant_types = ?,
837                    response_types = ?,
838                    pkce_required = ?,
839                    backchannel_logout_uri = ?
840                 WHERE client_id = ?",
841            )
842            .bind(&c.client_secret_hash)
843            .bind(encode_json_array(&c.redirect_uris))
844            .bind(&c.name)
845            .bind(&c.logo_url)
846            .bind(c.token_endpoint_auth_method.as_str())
847            .bind(encode_json_array(&c.default_scopes))
848            .bind(b(c.require_consent))
849            .bind(encode_json_array(&c.grant_types))
850            .bind(encode_json_array(&c.response_types))
851            .bind(b(c.pkce_required))
852            .bind(&c.backchannel_logout_uri)
853            .bind(&c.client_id)
854            .execute(&self.pool)
855            .await
856            .context("auth.oidc_clients update")?;
857            Ok(())
858        }
859
860        async fn delete(&self, client_id: &str) -> Result<bool> {
861            let r = sqlx::query("DELETE FROM auth.oidc_clients WHERE client_id = ?")
862                .bind(client_id)
863                .execute(&self.pool)
864                .await
865                .context("auth.oidc_clients delete")?;
866            Ok(r.rows_affected() > 0)
867        }
868
869        async fn rotate_secret_hash(&self, client_id: &str, new_hash: &str) -> Result<bool> {
870            let r = sqlx::query(
871                "UPDATE auth.oidc_clients SET client_secret_hash = ? WHERE client_id = ?",
872            )
873            .bind(new_hash)
874            .bind(client_id)
875            .execute(&self.pool)
876            .await
877            .context("auth.oidc_clients rotate_secret_hash")?;
878            Ok(r.rows_affected() > 0)
879        }
880    }
881
882    fn map_upstream_row(row: sqlx::sqlite::SqliteRow) -> UpstreamProvider {
883        let scopes_str: String = row.try_get("scopes").unwrap_or_default();
884        let auth_params_str: String = row.try_get("auth_params").unwrap_or_default();
885        UpstreamProvider {
886            slug: row.get("slug"),
887            issuer: row.get("issuer"),
888            client_id: row.get("client_id"),
889            client_secret: row.get("client_secret"),
890            display_name: row.get("display_name"),
891            icon_url: row.get("icon_url"),
892            enabled: ub(row.get("enabled")),
893            scopes: parse_json_array(&scopes_str),
894            auth_params: parse_auth_params(&auth_params_str),
895        }
896    }
897
898    fn parse_auth_params(s: &str) -> BTreeMap<String, String> {
899        if s.is_empty() {
900            return BTreeMap::new();
901        }
902        serde_json::from_str(s).unwrap_or_default()
903    }
904
905    fn encode_auth_params(map: &BTreeMap<String, String>) -> String {
906        serde_json::to_string(map).unwrap_or_else(|_| "{}".to_string())
907    }
908
909    #[derive(Clone)]
910    pub struct SqliteOidcUpstreamStore {
911        pool: SqlitePool,
912    }
913    impl SqliteOidcUpstreamStore {
914        pub fn new(pool: SqlitePool) -> Self {
915            Self { pool }
916        }
917        pub fn into_dyn(self) -> Arc<dyn OidcUpstreamStore> {
918            Arc::new(self)
919        }
920    }
921
922    #[async_trait]
923    impl OidcUpstreamStore for SqliteOidcUpstreamStore {
924        async fn upsert(&self, p: &UpstreamProvider) -> Result<()> {
925            sqlx::query(
926                "INSERT INTO auth.upstream_providers
927                    (slug, issuer, client_id, client_secret, display_name, icon_url,
928                     enabled, scopes, auth_params)
929                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
930                 ON CONFLICT (slug) DO UPDATE SET
931                    issuer = excluded.issuer,
932                    client_id = excluded.client_id,
933                    client_secret = excluded.client_secret,
934                    display_name = excluded.display_name,
935                    icon_url = excluded.icon_url,
936                    enabled = excluded.enabled,
937                    scopes = excluded.scopes,
938                    auth_params = excluded.auth_params",
939            )
940            .bind(&p.slug)
941            .bind(&p.issuer)
942            .bind(&p.client_id)
943            .bind(&p.client_secret)
944            .bind(&p.display_name)
945            .bind(&p.icon_url)
946            .bind(b(p.enabled))
947            .bind(encode_json_array(&p.scopes))
948            .bind(encode_auth_params(&p.auth_params))
949            .execute(&self.pool)
950            .await
951            .context("auth.upstream_providers upsert")?;
952            Ok(())
953        }
954
955        async fn get(&self, slug: &str) -> Result<Option<UpstreamProvider>> {
956            let row = sqlx::query("SELECT * FROM auth.upstream_providers WHERE slug = ?")
957                .bind(slug)
958                .fetch_optional(&self.pool)
959                .await
960                .context("auth.upstream_providers get")?;
961            Ok(row.map(map_upstream_row))
962        }
963
964        async fn list(&self) -> Result<Vec<UpstreamProvider>> {
965            let rows = sqlx::query("SELECT * FROM auth.upstream_providers ORDER BY slug")
966                .fetch_all(&self.pool)
967                .await
968                .context("auth.upstream_providers list")?;
969            Ok(rows.into_iter().map(map_upstream_row).collect())
970        }
971
972        async fn delete(&self, slug: &str) -> Result<bool> {
973            let r = sqlx::query("DELETE FROM auth.upstream_providers WHERE slug = ?")
974                .bind(slug)
975                .execute(&self.pool)
976                .await
977                .context("auth.upstream_providers delete")?;
978            Ok(r.rows_affected() > 0)
979        }
980    }
981
982    fn map_code_row(row: sqlx::sqlite::SqliteRow) -> AuthorizationCode {
983        AuthorizationCode {
984            code: row.get("code"),
985            client_id: row.get("client_id"),
986            user_id: row.get("user_id"),
987            redirect_uri: row.get("redirect_uri"),
988            scopes: parse_json_array(&row.get::<String, _>("scopes")),
989            code_challenge: row.get("code_challenge"),
990            code_challenge_method: row.get("code_challenge_method"),
991            nonce: row.get("nonce"),
992            state: row.get("state"),
993            issued_at: row.get("issued_at"),
994            expires_at: row.get("expires_at"),
995            consumed: ub(row.get("consumed")),
996        }
997    }
998
999    #[derive(Clone)]
1000    pub struct SqliteOidcCodeStore {
1001        pool: SqlitePool,
1002    }
1003    impl SqliteOidcCodeStore {
1004        pub fn new(pool: SqlitePool) -> Self {
1005            Self { pool }
1006        }
1007        pub fn into_dyn(self) -> Arc<dyn OidcCodeStore> {
1008            Arc::new(self)
1009        }
1010    }
1011
1012    #[async_trait]
1013    impl OidcCodeStore for SqliteOidcCodeStore {
1014        async fn create(&self, c: &AuthorizationCode) -> Result<()> {
1015            sqlx::query(
1016                "INSERT INTO auth.oidc_authorization_codes
1017                    (code, client_id, user_id, redirect_uri, scopes,
1018                     code_challenge, code_challenge_method, nonce, state,
1019                     issued_at, expires_at, consumed)
1020                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1021            )
1022            .bind(&c.code)
1023            .bind(&c.client_id)
1024            .bind(&c.user_id)
1025            .bind(&c.redirect_uri)
1026            .bind(encode_json_array(&c.scopes))
1027            .bind(&c.code_challenge)
1028            .bind(&c.code_challenge_method)
1029            .bind(&c.nonce)
1030            .bind(&c.state)
1031            .bind(c.issued_at)
1032            .bind(c.expires_at)
1033            .bind(b(c.consumed))
1034            .execute(&self.pool)
1035            .await
1036            .context("auth.oidc_authorization_codes insert")?;
1037            Ok(())
1038        }
1039
1040        async fn consume(&self, code: &str) -> Result<Option<AuthorizationCode>> {
1041            // SQLite has no RETURNING-after-UPDATE round-trip helper for
1042            // every backend version; do the load + conditional-update in
1043            // a transaction to preserve "consume returns row" semantics.
1044            let mut tx = self.pool.begin().await.context("begin consume tx")?;
1045            let row = sqlx::query(
1046                "SELECT * FROM auth.oidc_authorization_codes
1047                 WHERE code = ? AND consumed = 0",
1048            )
1049            .bind(code)
1050            .fetch_optional(&mut *tx)
1051            .await
1052            .context("auth.oidc_authorization_codes consume select")?;
1053            let Some(row) = row else {
1054                tx.rollback().await.ok();
1055                return Ok(None);
1056            };
1057            let result = sqlx::query(
1058                "UPDATE auth.oidc_authorization_codes SET consumed = 1 \
1059                 WHERE code = ? AND consumed = 0",
1060            )
1061            .bind(code)
1062            .execute(&mut *tx)
1063            .await
1064            .context("auth.oidc_authorization_codes consume update")?;
1065            if result.rows_affected() == 0 {
1066                tx.rollback().await.ok();
1067                return Ok(None);
1068            }
1069            tx.commit().await.context("commit consume tx")?;
1070            Ok(Some(map_code_row(row)))
1071        }
1072    }
1073
1074    fn map_refresh_row(row: sqlx::sqlite::SqliteRow) -> RefreshToken {
1075        RefreshToken {
1076            token_hash: row.get("token_hash"),
1077            client_id: row.get("client_id"),
1078            user_id: row.get("user_id"),
1079            scopes: parse_json_array(&row.get::<String, _>("scopes")),
1080            issued_at: row.get("issued_at"),
1081            expires_at: row.get("expires_at"),
1082            revoked: ub(row.get("revoked")),
1083        }
1084    }
1085
1086    #[derive(Clone)]
1087    pub struct SqliteOidcRefreshStore {
1088        pool: SqlitePool,
1089    }
1090    impl SqliteOidcRefreshStore {
1091        pub fn new(pool: SqlitePool) -> Self {
1092            Self { pool }
1093        }
1094        pub fn into_dyn(self) -> Arc<dyn OidcRefreshStore> {
1095            Arc::new(self)
1096        }
1097    }
1098
1099    #[async_trait]
1100    impl OidcRefreshStore for SqliteOidcRefreshStore {
1101        async fn create(&self, t: &RefreshToken) -> Result<()> {
1102            sqlx::query(
1103                "INSERT INTO auth.oidc_refresh_tokens
1104                    (token_hash, client_id, user_id, scopes,
1105                     issued_at, expires_at, revoked)
1106                 VALUES (?, ?, ?, ?, ?, ?, ?)",
1107            )
1108            .bind(&t.token_hash)
1109            .bind(&t.client_id)
1110            .bind(&t.user_id)
1111            .bind(encode_json_array(&t.scopes))
1112            .bind(t.issued_at)
1113            .bind(t.expires_at)
1114            .bind(b(t.revoked))
1115            .execute(&self.pool)
1116            .await
1117            .context("auth.oidc_refresh_tokens insert")?;
1118            Ok(())
1119        }
1120
1121        async fn get(&self, token_hash: &str) -> Result<Option<RefreshToken>> {
1122            let row = sqlx::query("SELECT * FROM auth.oidc_refresh_tokens WHERE token_hash = ?")
1123                .bind(token_hash)
1124                .fetch_optional(&self.pool)
1125                .await
1126                .context("auth.oidc_refresh_tokens get")?;
1127            Ok(row.map(map_refresh_row))
1128        }
1129
1130        async fn revoke(&self, token_hash: &str) -> Result<bool> {
1131            let r =
1132                sqlx::query("UPDATE auth.oidc_refresh_tokens SET revoked = 1 WHERE token_hash = ?")
1133                    .bind(token_hash)
1134                    .execute(&self.pool)
1135                    .await
1136                    .context("auth.oidc_refresh_tokens revoke")?;
1137            Ok(r.rows_affected() > 0)
1138        }
1139
1140        async fn revoke_for_user(&self, user_id: &str) -> Result<u64> {
1141            let r =
1142                sqlx::query("UPDATE auth.oidc_refresh_tokens SET revoked = 1 WHERE user_id = ?")
1143                    .bind(user_id)
1144                    .execute(&self.pool)
1145                    .await
1146                    .context("auth.oidc_refresh_tokens revoke_for_user")?;
1147            Ok(r.rows_affected())
1148        }
1149    }
1150
1151    fn map_session_row(row: sqlx::sqlite::SqliteRow) -> OidcSession {
1152        OidcSession {
1153            sid: row.get("sid"),
1154            user_id: row.get("user_id"),
1155            client_id: row.get("client_id"),
1156            assay_session_id: row.get("assay_session_id"),
1157            issued_at: row.get("issued_at"),
1158            backchannel_logout_uri: row.get("backchannel_logout_uri"),
1159        }
1160    }
1161
1162    #[derive(Clone)]
1163    pub struct SqliteOidcSessionStore {
1164        pool: SqlitePool,
1165    }
1166    impl SqliteOidcSessionStore {
1167        pub fn new(pool: SqlitePool) -> Self {
1168            Self { pool }
1169        }
1170        pub fn into_dyn(self) -> Arc<dyn OidcSessionStore> {
1171            Arc::new(self)
1172        }
1173    }
1174
1175    #[async_trait]
1176    impl OidcSessionStore for SqliteOidcSessionStore {
1177        async fn create(&self, s: &OidcSession) -> Result<()> {
1178            sqlx::query(
1179                "INSERT INTO auth.oidc_sessions
1180                    (sid, user_id, client_id, assay_session_id, issued_at, backchannel_logout_uri)
1181                 VALUES (?, ?, ?, ?, ?, ?)",
1182            )
1183            .bind(&s.sid)
1184            .bind(&s.user_id)
1185            .bind(&s.client_id)
1186            .bind(&s.assay_session_id)
1187            .bind(s.issued_at)
1188            .bind(&s.backchannel_logout_uri)
1189            .execute(&self.pool)
1190            .await
1191            .context("auth.oidc_sessions insert")?;
1192            Ok(())
1193        }
1194
1195        async fn get(&self, sid: &str) -> Result<Option<OidcSession>> {
1196            let row = sqlx::query("SELECT * FROM auth.oidc_sessions WHERE sid = ?")
1197                .bind(sid)
1198                .fetch_optional(&self.pool)
1199                .await
1200                .context("auth.oidc_sessions get")?;
1201            Ok(row.map(map_session_row))
1202        }
1203
1204        async fn list_by_assay_session(&self, assay_session_id: &str) -> Result<Vec<OidcSession>> {
1205            let rows = sqlx::query("SELECT * FROM auth.oidc_sessions WHERE assay_session_id = ?")
1206                .bind(assay_session_id)
1207                .fetch_all(&self.pool)
1208                .await
1209                .context("auth.oidc_sessions list_by_assay_session")?;
1210            Ok(rows.into_iter().map(map_session_row).collect())
1211        }
1212
1213        async fn delete(&self, sid: &str) -> Result<bool> {
1214            let r = sqlx::query("DELETE FROM auth.oidc_sessions WHERE sid = ?")
1215                .bind(sid)
1216                .execute(&self.pool)
1217                .await
1218                .context("auth.oidc_sessions delete")?;
1219            Ok(r.rows_affected() > 0)
1220        }
1221
1222        async fn delete_by_assay_session(&self, assay_session_id: &str) -> Result<u64> {
1223            let r = sqlx::query("DELETE FROM auth.oidc_sessions WHERE assay_session_id = ?")
1224                .bind(assay_session_id)
1225                .execute(&self.pool)
1226                .await
1227                .context("auth.oidc_sessions delete_by_assay_session")?;
1228            Ok(r.rows_affected())
1229        }
1230    }
1231
1232    fn map_consent_row(row: sqlx::sqlite::SqliteRow) -> ConsentGrant {
1233        ConsentGrant {
1234            user_id: row.get("user_id"),
1235            client_id: row.get("client_id"),
1236            scopes: parse_json_array(&row.get::<String, _>("scopes")),
1237            granted_at: row.get("granted_at"),
1238        }
1239    }
1240
1241    #[derive(Clone)]
1242    pub struct SqliteOidcConsentStore {
1243        pool: SqlitePool,
1244    }
1245    impl SqliteOidcConsentStore {
1246        pub fn new(pool: SqlitePool) -> Self {
1247            Self { pool }
1248        }
1249        pub fn into_dyn(self) -> Arc<dyn OidcConsentStore> {
1250            Arc::new(self)
1251        }
1252    }
1253
1254    #[async_trait]
1255    impl OidcConsentStore for SqliteOidcConsentStore {
1256        async fn upsert(&self, g: &ConsentGrant) -> Result<()> {
1257            sqlx::query(
1258                "INSERT INTO auth.oidc_consents (user_id, client_id, scopes, granted_at)
1259                 VALUES (?, ?, ?, ?)
1260                 ON CONFLICT (user_id, client_id) DO UPDATE
1261                     SET scopes = excluded.scopes,
1262                         granted_at = excluded.granted_at",
1263            )
1264            .bind(&g.user_id)
1265            .bind(&g.client_id)
1266            .bind(encode_json_array(&g.scopes))
1267            .bind(g.granted_at)
1268            .execute(&self.pool)
1269            .await
1270            .context("auth.oidc_consents upsert")?;
1271            Ok(())
1272        }
1273
1274        async fn get(&self, user_id: &str, client_id: &str) -> Result<Option<ConsentGrant>> {
1275            let row =
1276                sqlx::query("SELECT * FROM auth.oidc_consents WHERE user_id = ? AND client_id = ?")
1277                    .bind(user_id)
1278                    .bind(client_id)
1279                    .fetch_optional(&self.pool)
1280                    .await
1281                    .context("auth.oidc_consents get")?;
1282            Ok(row.map(map_consent_row))
1283        }
1284
1285        async fn delete(&self, user_id: &str, client_id: &str) -> Result<bool> {
1286            let r =
1287                sqlx::query("DELETE FROM auth.oidc_consents WHERE user_id = ? AND client_id = ?")
1288                    .bind(user_id)
1289                    .bind(client_id)
1290                    .execute(&self.pool)
1291                    .await
1292                    .context("auth.oidc_consents delete")?;
1293            Ok(r.rows_affected() > 0)
1294        }
1295    }
1296
1297    fn map_upstream_state_row(row: sqlx::sqlite::SqliteRow) -> UpstreamLoginState {
1298        UpstreamLoginState {
1299            state: row.get("state"),
1300            provider_slug: row.get("provider_slug"),
1301            nonce: row.get("nonce"),
1302            pkce_verifier: row.get("pkce_verifier"),
1303            return_to: row.get("return_to"),
1304            created_at: row.get("created_at"),
1305            expires_at: row.get("expires_at"),
1306            binding_hash: row.try_get("binding_hash").unwrap_or_default(),
1307        }
1308    }
1309
1310    #[derive(Clone)]
1311    pub struct SqliteOidcUpstreamStateStore {
1312        pool: SqlitePool,
1313    }
1314    impl SqliteOidcUpstreamStateStore {
1315        pub fn new(pool: SqlitePool) -> Self {
1316            Self { pool }
1317        }
1318        pub fn into_dyn(self) -> Arc<dyn OidcUpstreamStateStore> {
1319            Arc::new(self)
1320        }
1321    }
1322
1323    #[async_trait]
1324    impl OidcUpstreamStateStore for SqliteOidcUpstreamStateStore {
1325        async fn create(&self, s: &UpstreamLoginState) -> Result<()> {
1326            sqlx::query(
1327                "INSERT INTO auth.oidc_upstream_states
1328                    (state, provider_slug, nonce, pkce_verifier, return_to,
1329                     created_at, expires_at, binding_hash)
1330                 VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
1331            )
1332            .bind(&s.state)
1333            .bind(&s.provider_slug)
1334            .bind(&s.nonce)
1335            .bind(&s.pkce_verifier)
1336            .bind(&s.return_to)
1337            .bind(s.created_at)
1338            .bind(s.expires_at)
1339            .bind(&s.binding_hash)
1340            .execute(&self.pool)
1341            .await
1342            .context("auth.oidc_upstream_states insert")?;
1343            Ok(())
1344        }
1345
1346        async fn take(&self, state: &str) -> Result<Option<UpstreamLoginState>> {
1347            // SQLite likewise lacks RETURNING in some sqlx mappings; do a
1348            // load + delete in a tx for parity with the PG behaviour.
1349            let mut tx = self.pool.begin().await.context("begin take tx")?;
1350            let row = sqlx::query("SELECT * FROM auth.oidc_upstream_states WHERE state = ?")
1351                .bind(state)
1352                .fetch_optional(&mut *tx)
1353                .await
1354                .context("auth.oidc_upstream_states take select")?;
1355            let Some(row) = row else {
1356                tx.rollback().await.ok();
1357                return Ok(None);
1358            };
1359            sqlx::query("DELETE FROM auth.oidc_upstream_states WHERE state = ?")
1360                .bind(state)
1361                .execute(&mut *tx)
1362                .await
1363                .context("auth.oidc_upstream_states take delete")?;
1364            tx.commit().await.context("commit take tx")?;
1365            Ok(Some(map_upstream_state_row(row)))
1366        }
1367    }
1368}
1369
1370#[cfg(feature = "backend-sqlite")]
1371pub use sqlite_impl::{
1372    SqliteOidcClientStore, SqliteOidcCodeStore, SqliteOidcConsentStore, SqliteOidcRefreshStore,
1373    SqliteOidcSessionStore, SqliteOidcUpstreamStateStore, SqliteOidcUpstreamStore,
1374};