Skip to main content

authkestra_store_sqlx/
lib.rs

1//! Native, batteries-included SQL storage for `authkestra-op`'s `OpStore`,
2//! via [`sqlx`]. Split out of `authkestra-op` itself (authkestra#289) so
3//! that core has zero `sqlx` dependency — anyone wanting SQL-backed storage
4//! adds this satellite crate explicitly, selecting one or more of the
5//! `postgres`/`mysql`/`sqlite` features for the backend(s) they need.
6//!
7//! While a generic `KvStore` exists in `authkestra-engine` for JSON blob
8//! persistence across the framework, the OpenID Provider's [`SqlxOpStore`]
9//! is deliberately opinionated instead: highly normalized SQL tables with
10//! proper relational foreign keys, `ON DELETE CASCADE` constraints, and
11//! strictly defined columns (`client_id`, `scopes`, `expires_at`, ...).
12
13use async_trait::async_trait;
14use authkestra_op::client::{ClientRegistration, ClientStore, TokenEndpointAuthMethod};
15use authkestra_op::code::{AuthorizationCode, AuthorizationCodeStore};
16use authkestra_op::device::{DeviceCodeSession, DeviceCodeStore};
17use authkestra_op::refresh::{RefreshToken, RefreshTokenStore};
18
19// Model for database rows, using sqlx::FromRow would require fields to match perfectly.
20// Since we are mapping JSON strings back into structs, we will map rows manually.
21
22/// Opinionated, native SQL implementation of OpStore using sqlx.
23#[derive(Debug)]
24#[non_exhaustive]
25pub struct SqlxOpStore<DB: sqlx::Database> {
26    pool: sqlx::Pool<DB>,
27}
28
29// Written by hand rather than `#[derive(Clone)]`: the derive macro adds a
30// `DB: Clone` bound on the generic parameter, but `sqlx::Pool<DB>` is
31// already cheaply cloneable (it's a handle around a shared connection pool)
32// regardless of whether the `Database` marker type itself implements
33// `Clone` — Postgres/MySql/Sqlite don't.
34impl<DB: sqlx::Database> SqlxOpStore<DB> {
35    /// Check a connection out of the pool for one query.
36    ///
37    /// Every `OpStore` method on the pool-backed store goes through here,
38    /// which is the only thing that distinguishes it from `SqlxOpStoreTx` —
39    /// that one already owns a connection with a transaction open on it.
40    async fn conn(
41        &self,
42    ) -> Result<sqlx::pool::PoolConnection<DB>, authkestra_engine::store::StoreError> {
43        self.pool.acquire().await.map_err(|e| {
44            tracing::error!(error = %e, "sqlx pool acquire error");
45            authkestra_engine::store::StoreError::Internal(format!("db error: {e}"))
46        })
47    }
48}
49
50impl<DB: sqlx::Database> Clone for SqlxOpStore<DB> {
51    fn clone(&self) -> Self {
52        Self {
53            pool: self.pool.clone(),
54        }
55    }
56}
57
58/// A [`SqlxOpStore`] scoped to one open `sqlx` transaction — every store
59/// method called on it runs inside that transaction, and nothing lands until
60/// [`commit`](Self::commit).
61///
62/// Obtain one from [`SqlxOpStore::begin_tx`]. Dropping it without committing
63/// rolls back, so an early `?` in the middle of a composed unit of work
64/// cannot leave a half-written state behind.
65///
66/// # Composing with the host application's own writes
67///
68/// This is the point of the type. [`as_mut`](AsMut::as_mut) hands back the
69/// live connection, so the application's own statements and the store's run
70/// in the same transaction and commit or roll back together:
71///
72/// ```ignore
73/// let mut tx = store.begin_tx().await?;
74///
75/// sqlx::query("INSERT INTO app_users (id, email) VALUES (?1, ?2)")
76///     .bind(&user_id)
77///     .bind(&email)
78///     .execute(tx.as_mut())
79///     .await?;
80///
81/// tx.store_token(refresh_token).await?;   // same transaction
82///
83/// tx.commit().await?;                     // both, or neither
84/// ```
85///
86/// Reaching the native connection is deliberately an inherent method rather
87/// than something on the [`OpStoreTransaction`](authkestra_op::store::OpStoreTransaction)
88/// trait: the host's own queries are written against a concrete driver, so
89/// there is nothing for a backend-agnostic trait to usefully return here.
90/// Keeping it off the trait is what lets the trait stay dyn-compatible.
91#[derive(Debug)]
92#[non_exhaustive]
93pub struct SqlxOpStoreTx<DB: sqlx::Database> {
94    tx: sqlx::Transaction<'static, DB>,
95}
96
97/// The live connection inside this transaction, for the host application's own
98/// statements — `tx.as_mut()`, exactly as it reads on a bare
99/// [`sqlx::Transaction`], so a caller's queries look the way they would
100/// anywhere else in their code.
101impl<DB: sqlx::Database> AsMut<DB::Connection> for SqlxOpStoreTx<DB> {
102    fn as_mut(&mut self) -> &mut DB::Connection {
103        &mut self.tx
104    }
105}
106
107impl<DB: sqlx::Database> SqlxOpStoreTx<DB> {
108    /// Commit, making every write in this transaction durable at once.
109    pub async fn commit(self) -> Result<(), authkestra_engine::store::StoreError> {
110        self.tx.commit().await.map_err(|e| {
111            tracing::error!(error = %e, "sqlx commit error");
112            authkestra_engine::store::StoreError::Internal(format!("db error: {e}"))
113        })
114    }
115
116    /// Roll back, discarding every write in this transaction.
117    ///
118    /// Dropping does this too; calling it explicitly is how a caller gets to
119    /// see a rollback failure rather than have `Drop` swallow it.
120    pub async fn rollback(self) -> Result<(), authkestra_engine::store::StoreError> {
121        self.tx.rollback().await.map_err(|e| {
122            tracing::error!(error = %e, "sqlx rollback error");
123            authkestra_engine::store::StoreError::Internal(format!("db error: {e}"))
124        })
125    }
126}
127
128/// Add `column` to `table` (in the `authkestra` schema) if it isn't
129/// already there.
130///
131/// Postgres *does* support `ADD COLUMN IF NOT EXISTS`, but it still takes
132/// an ACCESS EXCLUSIVE lock on the relation before discovering there is
133/// nothing to do — and because Postgres lock requests are FIFO, an ALTER
134/// that queues behind a long-running transaction blocks every subsequent
135/// read of that table until the blocker clears. `migrate()` runs at every
136/// startup, so probing the catalog first keeps the steady state lock-free.
137/// The `IF NOT EXISTS` is retained *inside* the guard so a concurrently
138/// starting replica racing the same ALTER is still handled by Postgres
139/// itself, under the lock — which is why this backend needs no equivalent
140/// of `is_sqlite_duplicate_column`/`is_mysql_duplicate_column`.
141///
142/// `table_schema` is not optional here: `information_schema.columns` spans
143/// every schema the role can see, so an unqualified probe would be
144/// satisfied by a host application's own same-named table in `public` and
145/// would skip an ALTER that `authkestra`'s table still needs.
146#[cfg(feature = "postgres")]
147async fn ensure_postgres_column(
148    pool: &sqlx::PgPool,
149    table: &str,
150    column: &str,
151    add_column_ddl: &str,
152) -> Result<(), sqlx::Error> {
153    let exists: i64 = sqlx::query_scalar(
154        "SELECT COUNT(*) FROM information_schema.columns
155         WHERE table_schema = 'authkestra' AND table_name = $1 AND column_name = $2",
156    )
157    .bind(table)
158    .bind(column)
159    .fetch_one(pool)
160    .await?;
161    if exists == 0 {
162        sqlx::query(&format!(
163            "ALTER TABLE authkestra.{table} ADD COLUMN IF NOT EXISTS {add_column_ddl}"
164        ))
165        .execute(pool)
166        .await?;
167    }
168    Ok(())
169}
170
171/// True only for "the column is already there" — the exact error the loser
172/// of a check-then-ALTER race gets when another process added the column
173/// between our `pragma_table_info` probe and our `ALTER`.
174///
175/// SQLite reports this as plain `SQLITE_ERROR` (1), the same extended
176/// result code as a syntax error or a missing table, so `code()` cannot
177/// discriminate it. The message is the only reliable signal, and its text
178/// is fixed in SQLite's `alter.c` as `duplicate column name: <name>`.
179#[cfg(feature = "sqlite")]
180fn is_sqlite_duplicate_column(e: &sqlx::Error) -> bool {
181    // `message()` resolves through the `dyn DatabaseError` object itself, so
182    // no trait import is needed here (nor in the MySQL classifier below).
183    e.as_database_error()
184        .is_some_and(|db| db.message().starts_with("duplicate column name:"))
185}
186
187/// True only for MySQL `ER_DUP_FIELDNAME` (1060, SQLSTATE 42S21) — see the
188/// SQLite equivalent above for why this narrow tolerance exists.
189///
190/// Matched on the error *number*, not the message or SQLSTATE: a missing
191/// table (1146) or a bad column type (1064) stays fatal.
192#[cfg(feature = "mysql")]
193fn is_mysql_duplicate_column(e: &sqlx::Error) -> bool {
194    e.as_database_error()
195        .and_then(|db| db.try_downcast_ref::<sqlx::mysql::MySqlDatabaseError>())
196        .is_some_and(|db| db.number() == 1060)
197}
198
199/// Add `column` to `table` if it isn't already there.
200///
201/// SQLite has no `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` (unlike
202/// Postgres 9.6+), so this introspects via `pragma_table_info` first. Used
203/// by [`SqlxOpStore::<sqlx::Sqlite>::migrate`] instead of `sqlx::migrate!` —
204/// see that function's doc comment for why.
205#[cfg(feature = "sqlite")]
206async fn ensure_sqlite_column(
207    pool: &sqlx::SqlitePool,
208    table: &str,
209    column: &str,
210    add_column_ddl: &str,
211) -> Result<(), sqlx::Error> {
212    let exists: i64 = sqlx::query_scalar(&format!(
213        "SELECT COUNT(*) FROM pragma_table_info('{table}') WHERE name = ?"
214    ))
215    .bind(column)
216    .fetch_one(pool)
217    .await?;
218    if exists == 0 {
219        // A concurrently-starting replica may have added the column between
220        // the probe above and here; that is the *intended* end state, so it
221        // is success, not a failed migration. Nothing else is tolerated —
222        // see `is_sqlite_duplicate_column`.
223        if let Err(e) = sqlx::query(&format!("ALTER TABLE {table} ADD COLUMN {add_column_ddl}"))
224            .execute(pool)
225            .await
226        {
227            if !is_sqlite_duplicate_column(&e) {
228                return Err(e);
229            }
230        }
231    }
232    Ok(())
233}
234
235/// Add `column` to `table` if it isn't already there.
236///
237/// MySQL has no universally-available `ADD COLUMN IF NOT EXISTS` across
238/// commonly-deployed versions, so this introspects via
239/// `information_schema.columns` first. Used by
240/// [`SqlxOpStore::<sqlx::MySql>::migrate`] instead of `sqlx::migrate!` —
241/// see the Postgres impl's doc comment for why.
242#[cfg(feature = "mysql")]
243async fn ensure_mysql_column(
244    pool: &sqlx::MySqlPool,
245    table: &str,
246    column: &str,
247    add_column_ddl: &str,
248) -> Result<(), sqlx::Error> {
249    let exists: i64 = sqlx::query_scalar(
250        "SELECT COUNT(*) FROM information_schema.columns
251         WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?",
252    )
253    .bind(table)
254    .bind(column)
255    .fetch_one(pool)
256    .await?;
257    if exists == 0 {
258        // See `ensure_sqlite_column`'s identical comment: only the losing
259        // side of a check-then-ALTER race is tolerated here.
260        if let Err(e) = sqlx::query(&format!("ALTER TABLE {table} ADD COLUMN {add_column_ddl}"))
261            .execute(pool)
262            .await
263        {
264            if !is_mysql_duplicate_column(&e) {
265                return Err(e);
266            }
267        }
268    }
269    Ok(())
270}
271
272macro_rules! impl_opstore_sql {
273    (
274        $backend:path,
275        $feature:literal,
276        $queries:ident,
277        $placeholder_fmt:expr,
278        $schema_prefix:literal,
279        $migrate_impl:item,
280        $consume_code_fn:item,
281        $consume_token_fn:item,
282        $consume_device_fn:item,
283        $dpop_jti_fn:item
284    ) => {
285        /// Every query this backend runs, written once against a plain
286        /// connection rather than against a pool, so the pool-backed store
287        /// and the transaction-scoped store below can share them verbatim
288        /// instead of keeping two copies of the same SQL in sync.
289        ///
290        /// Taking `&mut Conn` (not a generic `sqlx::Executor`) is what makes
291        /// the composed case correct: a `Connection` can `begin()`, and sqlx
292        /// turns a `begin()` on a connection that is already inside a
293        /// transaction into a SAVEPOINT. So the consume paths that need their
294        /// own transaction (MySQL's `SELECT ... FOR UPDATE`) get a real one
295        /// when called on the pool, and a nested savepoint — governed by the
296        /// caller's commit — when called inside a host transaction.
297        #[cfg(feature = $feature)]
298        pub(crate) mod $queries {
299            use super::*;
300            #[allow(unused_imports)]
301            use sqlx::Connection as _;
302
303            /// The raw connection type this backend's queries run against.
304            pub(crate) type Conn = <$backend as sqlx::Database>::Connection;
305
306            #[allow(deprecated)] // `require_pkce` (authkestra#273) — still round-tripped for wire/storage compatibility
307            pub(crate) async fn find_client(
308                conn: &mut Conn,
309                client_id: &str,
310            ) -> Result<Option<ClientRegistration>, authkestra_engine::store::StoreError> {
311            let query = format!(
312                "SELECT
313                    client_id,
314                    client_secret_hash,
315                    require_pkce,
316                    redirect_uris,
317                    grant_types,
318                    scopes,
319                    allowed_audiences,
320                    token_endpoint_auth_method,
321                    jwks
322                FROM {schema}oauth_clients
323                WHERE client_id = {p1}",
324                schema = $schema_prefix,
325                p1 = $placeholder_fmt(1)
326            );
327
328            let row = sqlx::query(&query)
329                .bind(client_id)
330                .fetch_optional(&mut *conn)
331                .await
332                .map_err(|e| {
333                    tracing::error!(error = %e, "sqlx find_client error");
334                    authkestra_engine::store::StoreError::Internal(format!("db error: {e}"))
335                })?;
336
337            if let Some(row) = row {
338                use sqlx::Row;
339                let client_id: String = row.try_get("client_id").unwrap_or_default();
340                let client_secret_hash: Option<String> = row.try_get("client_secret_hash").unwrap_or_default();
341                let require_pkce: bool = row.try_get("require_pkce").unwrap_or(true);
342
343                // SQLite might return these as Strings (from TEXT) while Postgres might return JsonValue (from JSONB)
344                // The safest way across all drivers is to deserialize from whatever String they provide, or handle types cleanly.
345                // For now, we'll assume we can get it as a string or fallback. We will use `try_get` as string.
346                // Since sqlx::types::Json is cross-platform, we can use that!
347
348                let redirect_uris: sqlx::types::Json<Vec<String>> = row.try_get("redirect_uris").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
349                let grant_types: sqlx::types::Json<Vec<authkestra_op::client::GrantType>> = row.try_get("grant_types").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
350                let scopes: sqlx::types::Json<Vec<String>> = row.try_get("scopes").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
351                let allowed_audiences: sqlx::types::Json<Vec<String>> = row.try_get("allowed_audiences").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
352                // Nullable: a client registered before authkestra#287's
353                // migration added these columns simply has no value in
354                // them yet, same as any other pre-existing row and a
355                // newly-added nullable column. That's a genuine SQL
356                // NULL, which `try_get::<Option<Json<T>>, _>` already
357                // reports as `Ok(None)` — distinct from a non-NULL value
358                // that fails to decode, which it reports as `Err`.
359                // Collapsing both cases with `.ok()` would silently turn
360                // an operator-written value this enum doesn't model
361                // (e.g. `client_secret_jwt`) into `None`, and
362                // `authenticate_client` treats `None` as "no auth method
363                // configured" — fail-open into an unauthenticated
364                // client. Propagate the decode error instead.
365                let token_endpoint_auth_method: Option<TokenEndpointAuthMethod> = row
366                    .try_get::<Option<sqlx::types::Json<TokenEndpointAuthMethod>>, _>("token_endpoint_auth_method")
367                    .map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?
368                    .map(|j| j.0);
369                let jwks: Option<serde_json::Value> = row
370                    .try_get::<Option<sqlx::types::Json<serde_json::Value>>, _>("jwks")
371                    .map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?
372                    .map(|j| j.0);
373
374                Ok(Some(ClientRegistration {
375                    client_id,
376                    client_secret_hash,
377                    require_pkce,
378                    redirect_uris: redirect_uris.0,
379                    grant_types: grant_types.0,
380                    scopes: scopes.0,
381                    allowed_audiences: allowed_audiences.0,
382                    token_endpoint_auth_method,
383                    jwks,
384                }))
385            } else {
386                Ok(None)
387            }
388            }
389
390            pub(crate) async fn store_code(
391                conn: &mut Conn,
392                code: AuthorizationCode,
393            ) -> Result<(), authkestra_engine::store::StoreError> {
394            let query = format!(
395                "INSERT INTO {schema}oauth_codes 
396                (code, client_id, redirect_uri, scope, code_challenge, code_challenge_method, nonce, identity, expires_at, used) 
397                VALUES ({p1}, {p2}, {p3}, {p4}, {p5}, {p6}, {p7}, {p8}, {p9}, {p10})",
398                schema = $schema_prefix,
399                p1 = $placeholder_fmt(1), p2 = $placeholder_fmt(2), p3 = $placeholder_fmt(3),
400                p4 = $placeholder_fmt(4), p5 = $placeholder_fmt(5), p6 = $placeholder_fmt(6),
401                p7 = $placeholder_fmt(7), p8 = $placeholder_fmt(8), p9 = $placeholder_fmt(9),
402                p10 = $placeholder_fmt(10)
403            );
404
405            let identity_json = sqlx::types::Json(code.identity);
406
407            sqlx::query(&query)
408                .bind(code.code)
409                .bind(code.client_id)
410                .bind(code.redirect_uri)
411                .bind(code.scope)
412                .bind(code.code_challenge)
413                .bind(code.code_challenge_method)
414                .bind(code.nonce)
415                .bind(identity_json)
416                .bind(code.expires_at)
417                .bind(code.used)
418                .execute(&mut *conn)
419                .await
420                .map_err(|e| {
421                    tracing::error!(error = %e, "sqlx store_code error");
422                    authkestra_engine::store::StoreError::Internal(format!("db error: {e}"))
423                })?;
424            Ok(())
425            }
426
427            pub(crate) async fn store_token(
428                conn: &mut Conn,
429                token: RefreshToken,
430            ) -> Result<(), authkestra_engine::store::StoreError> {
431            let query = format!(
432                "INSERT INTO {schema}oauth_refresh_tokens
433                (token, client_id, identity, scope, expires_at, jkt)
434                VALUES ({p1}, {p2}, {p3}, {p4}, {p5}, {p6})",
435                schema = $schema_prefix,
436                p1 = $placeholder_fmt(1), p2 = $placeholder_fmt(2), p3 = $placeholder_fmt(3),
437                p4 = $placeholder_fmt(4), p5 = $placeholder_fmt(5), p6 = $placeholder_fmt(6)
438            );
439
440            let identity_json = sqlx::types::Json(token.identity);
441
442            sqlx::query(&query)
443                .bind(token.token)
444                .bind(token.client_id)
445                .bind(identity_json)
446                .bind(token.scope)
447                .bind(token.expires_at)
448                .bind(token.jkt)
449                .execute(&mut *conn)
450                .await
451                .map_err(|e| {
452                    tracing::error!(error = %e, "sqlx store_token error");
453                    authkestra_engine::store::StoreError::Internal(format!("db error: {e}"))
454                })?;
455            Ok(())
456            }
457
458            pub(crate) async fn get_token(
459                conn: &mut Conn,
460                token: &str,
461            ) -> Result<Option<RefreshToken>, authkestra_engine::store::StoreError> {
462            let query = format!(
463                "SELECT token, client_id, identity, scope, expires_at, jkt
464                FROM {schema}oauth_refresh_tokens
465                WHERE token = {p1} AND revoked_at IS NULL AND expires_at > {p2}",
466                schema = $schema_prefix,
467                p1 = $placeholder_fmt(1),
468                p2 = $placeholder_fmt(2)
469            );
470
471            let row = sqlx::query(&query)
472                .bind(token)
473                .bind(chrono::Utc::now())
474                .fetch_optional(&mut *conn)
475                .await
476                .map_err(|e| {
477                    tracing::error!(error = %e, "sqlx get_token error");
478                    authkestra_engine::store::StoreError::Internal(format!("db error: {e}"))
479                })?;
480
481            if let Some(row) = row {
482                use sqlx::Row;
483                let identity: sqlx::types::Json<authkestra_engine::auth::state::Identity> = row.try_get("identity").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
484
485                // NULL (a pre-authkestra#287 row, or a token never bound
486                // to a DPoP proof) is a legitimate `None`; a decode error
487                // on a non-NULL value is propagated rather than silently
488                // discarded, since that would undo the RFC 9449 §5
489                // continuity check this column exists to enforce.
490                Ok(Some(RefreshToken::new(
491                    row.try_get("token").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
492                    row.try_get("client_id").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
493                    identity.0,
494                    row.try_get("scope").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
495                    row.try_get("expires_at").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
496                    row.try_get("jkt").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
497                )))
498            } else {
499                Ok(None)
500            }
501            }
502
503            pub(crate) async fn revoke_token(
504                conn: &mut Conn,
505                token: &str,
506            ) -> Result<(), authkestra_engine::store::StoreError> {
507            let query = format!(
508                "UPDATE {schema}oauth_refresh_tokens SET revoked_at = {p1} WHERE token = {p2}",
509                schema = $schema_prefix,
510                p1 = $placeholder_fmt(1),
511                p2 = $placeholder_fmt(2)
512            );
513
514            sqlx::query(&query)
515                .bind(chrono::Utc::now())
516                .bind(token)
517                .execute(&mut *conn)
518                .await
519                .map_err(|e| {
520                    tracing::error!(error = %e, "sqlx revoke_token error");
521                    authkestra_engine::store::StoreError::Internal(format!("db error: {e}"))
522                })?;
523            Ok(())
524            }
525
526            pub(crate) async fn store_device_code(
527                conn: &mut Conn,
528                session: DeviceCodeSession,
529            ) -> Result<(), authkestra_engine::store::StoreError> {
530            let query = format!(
531                "INSERT INTO {schema}oauth_device_codes 
532                (device_code, user_code, client_id, scope, expires_at, status, last_polled_at) 
533                VALUES ({p1}, {p2}, {p3}, {p4}, {p5}, {p6}, {p7})",
534                schema = $schema_prefix,
535                p1 = $placeholder_fmt(1), p2 = $placeholder_fmt(2), p3 = $placeholder_fmt(3),
536                p4 = $placeholder_fmt(4), p5 = $placeholder_fmt(5), p6 = $placeholder_fmt(6),
537                p7 = $placeholder_fmt(7)
538            );
539
540            let status_json = sqlx::types::Json(session.status);
541
542            sqlx::query(&query)
543                .bind(session.device_code)
544                .bind(session.user_code)
545                .bind(session.client_id)
546                .bind(session.scope)
547                .bind(session.expires_at)
548                .bind(status_json)
549                .bind(session.last_polled_at)
550                .execute(&mut *conn)
551                .await
552                .map_err(|e| {
553                    tracing::error!(error = %e, "sqlx store_device_code error");
554                    authkestra_engine::store::StoreError::Internal(format!("db error: {e}"))
555                })?;
556            Ok(())
557            }
558
559            pub(crate) async fn get_device_code(
560                conn: &mut Conn,
561                device_code: &str,
562            ) -> Result<Option<DeviceCodeSession>, authkestra_engine::store::StoreError> {
563            let query = format!(
564                "SELECT device_code, user_code, client_id, scope, expires_at, status, last_polled_at 
565                FROM {schema}oauth_device_codes 
566                WHERE device_code = {p1}",
567                schema = $schema_prefix,
568                p1 = $placeholder_fmt(1)
569            );
570
571            let row = sqlx::query(&query)
572                .bind(device_code)
573                .fetch_optional(&mut *conn)
574                .await
575                .map_err(|e| {
576                    tracing::error!(error = %e, "sqlx get_device_code error");
577                    authkestra_engine::store::StoreError::Internal(format!("db error: {e}"))
578                })?;
579
580            if let Some(row) = row {
581                use sqlx::Row;
582                let status: sqlx::types::Json<authkestra_op::device::DeviceCodeStatus> = row.try_get("status").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
583
584                Ok(Some({
585                    let mut session = DeviceCodeSession::new(
586                    row.try_get("device_code").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
587                    row.try_get("user_code").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
588                    row.try_get("client_id").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
589                    row.try_get("scope").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
590                    row.try_get("expires_at").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
591                    status.0,
592                );
593                    session.last_polled_at = row.try_get("last_polled_at").ok();
594                    session
595                }))
596            } else {
597                Ok(None)
598            }
599            }
600
601            pub(crate) async fn get_by_user_code(
602                conn: &mut Conn,
603                user_code: &str,
604            ) -> Result<Option<DeviceCodeSession>, authkestra_engine::store::StoreError> {
605            let query = format!(
606                "SELECT device_code, user_code, client_id, scope, expires_at, status, last_polled_at 
607                FROM {schema}oauth_device_codes 
608                WHERE user_code = {p1}",
609                schema = $schema_prefix,
610                p1 = $placeholder_fmt(1)
611            );
612
613            let row = sqlx::query(&query)
614                .bind(user_code)
615                .fetch_optional(&mut *conn)
616                .await
617                .map_err(|e| {
618                    tracing::error!(error = %e, "sqlx get_by_user_code error");
619                    authkestra_engine::store::StoreError::Internal(format!("db error: {e}"))
620                })?;
621
622            if let Some(row) = row {
623                use sqlx::Row;
624                let status: sqlx::types::Json<authkestra_op::device::DeviceCodeStatus> = row.try_get("status").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
625
626                Ok(Some({
627                    let mut session = DeviceCodeSession::new(
628                    row.try_get("device_code").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
629                    row.try_get("user_code").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
630                    row.try_get("client_id").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
631                    row.try_get("scope").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
632                    row.try_get("expires_at").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
633                    status.0,
634                );
635                    session.last_polled_at = row.try_get("last_polled_at").ok();
636                    session
637                }))
638            } else {
639                Ok(None)
640            }
641            }
642
643            pub(crate) async fn update_device_code(
644                conn: &mut Conn,
645                session: DeviceCodeSession,
646            ) -> Result<(), authkestra_engine::store::StoreError> {
647            let query = format!(
648                "UPDATE {schema}oauth_device_codes 
649                SET status = {p1}, last_polled_at = {p2} 
650                WHERE device_code = {p3}",
651                schema = $schema_prefix,
652                p1 = $placeholder_fmt(1), p2 = $placeholder_fmt(2), p3 = $placeholder_fmt(3)
653            );
654
655            let status_json = sqlx::types::Json(session.status);
656
657            sqlx::query(&query)
658                .bind(status_json)
659                .bind(session.last_polled_at)
660                .bind(session.device_code)
661                .execute(&mut *conn)
662                .await
663                .map_err(|e| {
664                    tracing::error!(error = %e, "sqlx update_device_code error");
665                    authkestra_engine::store::StoreError::Internal(format!("db error: {e}"))
666                })?;
667            Ok(())
668            }
669
670            pub(crate) async fn delete_device_code(
671                conn: &mut Conn,
672                device_code: &str,
673            ) -> Result<(), authkestra_engine::store::StoreError> {
674            let query = format!(
675                "DELETE FROM {schema}oauth_device_codes WHERE device_code = {p1}",
676                schema = $schema_prefix,
677                p1 = $placeholder_fmt(1)
678            );
679
680            sqlx::query(&query)
681                .bind(device_code)
682                .execute(&mut *conn)
683                .await
684                .map_err(|e| {
685                    tracing::error!(error = %e, "sqlx delete_device_code error");
686                    authkestra_engine::store::StoreError::Internal(format!("db error: {e}"))
687                })?;
688            Ok(())
689            }
690
691            $consume_code_fn
692
693            $consume_token_fn
694
695            $consume_device_fn
696
697            $dpop_jti_fn
698        }
699
700        #[cfg(feature = $feature)]
701        impl SqlxOpStore<$backend> {
702            /// Create a new SqlxOpStore from a sqlx connection pool.
703            pub fn new(pool: sqlx::Pool<$backend>) -> Self {
704                Self { pool }
705            }
706
707            $migrate_impl
708
709            /// Begin a transaction and return a store scoped to it.
710            ///
711            /// The concrete counterpart to
712            /// [`TransactionalOpStore::begin`](authkestra_op::store::TransactionalOpStore::begin):
713            /// it returns [`SqlxOpStoreTx`] rather than a trait object, which is
714            /// what a host application needs — `SqlxOpStoreTx`'s `AsMut` impl hands
715            /// back the live `sqlx` connection so the application's own
716            /// statements run in the same transaction as the store's.
717            pub async fn begin_tx(&self) -> Result<SqlxOpStoreTx<$backend>, authkestra_engine::store::StoreError> {
718                let tx = self.pool.begin().await.map_err(|e| {
719                    tracing::error!(error = %e, "sqlx begin transaction error");
720                    authkestra_engine::store::StoreError::Internal(format!("db error: {e}"))
721                })?;
722                Ok(SqlxOpStoreTx { tx })
723            }
724        }
725
726        #[cfg(feature = $feature)]
727        #[async_trait]
728        impl authkestra_op::store::TransactionalOpStore for SqlxOpStore<$backend> {
729            async fn begin(
730                &self,
731            ) -> Result<Box<dyn authkestra_op::store::OpStoreTransaction + Send>, authkestra_engine::store::StoreError> {
732                Ok(Box::new(self.begin_tx().await?))
733            }
734        }
735
736        #[cfg(feature = $feature)]
737        #[async_trait]
738        impl authkestra_op::store::OpStoreTransaction for SqlxOpStoreTx<$backend> {
739            async fn commit(self: Box<Self>) -> Result<(), authkestra_engine::store::StoreError> {
740                SqlxOpStoreTx::commit(*self).await
741            }
742
743            async fn rollback(self: Box<Self>) -> Result<(), authkestra_engine::store::StoreError> {
744                SqlxOpStoreTx::rollback(*self).await
745            }
746        }
747
748        #[cfg(feature = $feature)]
749        #[async_trait]
750        impl authkestra_op::store::OpStore for SqlxOpStore<$backend> {
751            async fn check_and_record_dpop_jti(&mut self, jti: &str, expires_at: chrono::DateTime<chrono::Utc>) -> Result<bool, authkestra_engine::store::StoreError> {
752                let mut c = self.conn().await?;
753                let conn = &mut *c;
754                $queries::check_and_record_dpop_jti(conn, jti, expires_at).await
755            }
756        }
757
758        #[cfg(feature = $feature)]
759        #[async_trait]
760        impl ClientStore for SqlxOpStore<$backend> {
761            async fn find_client(&mut self, client_id: &str) -> Result<Option<ClientRegistration>, authkestra_engine::store::StoreError> {
762                let mut c = self.conn().await?;
763                let conn = &mut *c;
764                $queries::find_client(conn, client_id).await
765            }
766        }
767
768        #[cfg(feature = $feature)]
769        #[async_trait]
770        impl AuthorizationCodeStore for SqlxOpStore<$backend> {
771            async fn store_code(&mut self, code: AuthorizationCode) -> Result<(), authkestra_engine::store::StoreError> {
772                let mut c = self.conn().await?;
773                let conn = &mut *c;
774                $queries::store_code(conn, code).await
775            }
776            async fn consume_code(&mut self, code: &str) -> Result<Option<AuthorizationCode>, authkestra_engine::store::StoreError> {
777                let mut c = self.conn().await?;
778                let conn = &mut *c;
779                $queries::consume_code(conn, code).await
780            }
781        }
782
783        #[cfg(feature = $feature)]
784        #[async_trait]
785        impl RefreshTokenStore for SqlxOpStore<$backend> {
786            async fn store_token(&mut self, token: RefreshToken) -> Result<(), authkestra_engine::store::StoreError> {
787                let mut c = self.conn().await?;
788                let conn = &mut *c;
789                $queries::store_token(conn, token).await
790            }
791            async fn get_token(&mut self, token: &str) -> Result<Option<RefreshToken>, authkestra_engine::store::StoreError> {
792                let mut c = self.conn().await?;
793                let conn = &mut *c;
794                $queries::get_token(conn, token).await
795            }
796            async fn revoke_token(&mut self, token: &str) -> Result<(), authkestra_engine::store::StoreError> {
797                let mut c = self.conn().await?;
798                let conn = &mut *c;
799                $queries::revoke_token(conn, token).await
800            }
801            async fn consume_token(&mut self, token: &str) -> Result<Option<RefreshToken>, authkestra_engine::store::StoreError> {
802                let mut c = self.conn().await?;
803                let conn = &mut *c;
804                $queries::consume_token(conn, token).await
805            }
806        }
807
808        #[cfg(feature = $feature)]
809        #[async_trait]
810        impl DeviceCodeStore for SqlxOpStore<$backend> {
811            async fn store_device_code(&mut self, session: DeviceCodeSession) -> Result<(), authkestra_engine::store::StoreError> {
812                let mut c = self.conn().await?;
813                let conn = &mut *c;
814                $queries::store_device_code(conn, session).await
815            }
816            async fn get_device_code(&mut self, device_code: &str) -> Result<Option<DeviceCodeSession>, authkestra_engine::store::StoreError> {
817                let mut c = self.conn().await?;
818                let conn = &mut *c;
819                $queries::get_device_code(conn, device_code).await
820            }
821            async fn get_by_user_code(&mut self, user_code: &str) -> Result<Option<DeviceCodeSession>, authkestra_engine::store::StoreError> {
822                let mut c = self.conn().await?;
823                let conn = &mut *c;
824                $queries::get_by_user_code(conn, user_code).await
825            }
826            async fn update_device_code(&mut self, session: DeviceCodeSession) -> Result<(), authkestra_engine::store::StoreError> {
827                let mut c = self.conn().await?;
828                let conn = &mut *c;
829                $queries::update_device_code(conn, session).await
830            }
831            async fn delete_device_code(&mut self, device_code: &str) -> Result<(), authkestra_engine::store::StoreError> {
832                let mut c = self.conn().await?;
833                let conn = &mut *c;
834                $queries::delete_device_code(conn, device_code).await
835            }
836            async fn consume_device_code(&mut self, device_code: &str) -> Result<Option<DeviceCodeSession>, authkestra_engine::store::StoreError> {
837                let mut c = self.conn().await?;
838                let conn = &mut *c;
839                $queries::consume_device_code(conn, device_code).await
840            }
841        }
842
843        #[cfg(feature = $feature)]
844        #[async_trait]
845        impl authkestra_op::store::OpStore for SqlxOpStoreTx<$backend> {
846            async fn check_and_record_dpop_jti(&mut self, jti: &str, expires_at: chrono::DateTime<chrono::Utc>) -> Result<bool, authkestra_engine::store::StoreError> {
847                $queries::check_and_record_dpop_jti(&mut *self.tx, jti, expires_at).await
848            }
849        }
850
851        #[cfg(feature = $feature)]
852        #[async_trait]
853        impl ClientStore for SqlxOpStoreTx<$backend> {
854            async fn find_client(&mut self, client_id: &str) -> Result<Option<ClientRegistration>, authkestra_engine::store::StoreError> {
855                $queries::find_client(&mut *self.tx, client_id).await
856            }
857        }
858
859        #[cfg(feature = $feature)]
860        #[async_trait]
861        impl AuthorizationCodeStore for SqlxOpStoreTx<$backend> {
862            async fn store_code(&mut self, code: AuthorizationCode) -> Result<(), authkestra_engine::store::StoreError> {
863                $queries::store_code(&mut *self.tx, code).await
864            }
865            async fn consume_code(&mut self, code: &str) -> Result<Option<AuthorizationCode>, authkestra_engine::store::StoreError> {
866                $queries::consume_code(&mut *self.tx, code).await
867            }
868        }
869
870        #[cfg(feature = $feature)]
871        #[async_trait]
872        impl RefreshTokenStore for SqlxOpStoreTx<$backend> {
873            async fn store_token(&mut self, token: RefreshToken) -> Result<(), authkestra_engine::store::StoreError> {
874                $queries::store_token(&mut *self.tx, token).await
875            }
876            async fn get_token(&mut self, token: &str) -> Result<Option<RefreshToken>, authkestra_engine::store::StoreError> {
877                $queries::get_token(&mut *self.tx, token).await
878            }
879            async fn revoke_token(&mut self, token: &str) -> Result<(), authkestra_engine::store::StoreError> {
880                $queries::revoke_token(&mut *self.tx, token).await
881            }
882            async fn consume_token(&mut self, token: &str) -> Result<Option<RefreshToken>, authkestra_engine::store::StoreError> {
883                $queries::consume_token(&mut *self.tx, token).await
884            }
885        }
886
887        #[cfg(feature = $feature)]
888        #[async_trait]
889        impl DeviceCodeStore for SqlxOpStoreTx<$backend> {
890            async fn store_device_code(&mut self, session: DeviceCodeSession) -> Result<(), authkestra_engine::store::StoreError> {
891                $queries::store_device_code(&mut *self.tx, session).await
892            }
893            async fn get_device_code(&mut self, device_code: &str) -> Result<Option<DeviceCodeSession>, authkestra_engine::store::StoreError> {
894                $queries::get_device_code(&mut *self.tx, device_code).await
895            }
896            async fn get_by_user_code(&mut self, user_code: &str) -> Result<Option<DeviceCodeSession>, authkestra_engine::store::StoreError> {
897                $queries::get_by_user_code(&mut *self.tx, user_code).await
898            }
899            async fn update_device_code(&mut self, session: DeviceCodeSession) -> Result<(), authkestra_engine::store::StoreError> {
900                $queries::update_device_code(&mut *self.tx, session).await
901            }
902            async fn delete_device_code(&mut self, device_code: &str) -> Result<(), authkestra_engine::store::StoreError> {
903                $queries::delete_device_code(&mut *self.tx, device_code).await
904            }
905            async fn consume_device_code(&mut self, device_code: &str) -> Result<Option<DeviceCodeSession>, authkestra_engine::store::StoreError> {
906                $queries::consume_device_code(&mut *self.tx, device_code).await
907            }
908        }
909
910    };
911}
912
913// Generate the Postgres implementation
914impl_opstore_sql! {
915    sqlx::Postgres,
916    "postgres",
917    pg_queries,
918    |i| format!("${}", i),
919    "authkestra.",
920    /// Run necessary database migrations to set up schema and tables.
921    ///
922    /// Deliberately **not** `sqlx::migrate!` (tried in authkestra#287,
923    /// reverted): its bookkeeping lives in one database-global
924    /// `_sqlx_migrations` table with no supported way in sqlx 0.8 to
925    /// rename or namespace it. `authkestra-op` is a library embedded into
926    /// a host application's own connection pool — a host that also runs
927    /// `sqlx::migrate!` for its own schema against that same pool (the
928    /// common case) collides on that shared table the moment either side's
929    /// migration lands on a version number the other already used, and
930    /// `Migrator::run` then refuses to apply *either* set of migrations.
931    /// The old `CREATE TABLE IF NOT EXISTS`-only approach never had this
932    /// failure mode, because it kept no bookkeeping of its own at all —
933    /// this keeps that property while still being able to *add* a column
934    /// to an existing deployment's table. Postgres supports `ADD COLUMN IF
935    /// NOT EXISTS` directly (9.6+), so no introspection query is needed
936    /// here the way SQLite and MySQL's `migrate()` need one.
937    pub async fn migrate(&self) -> Result<(), sqlx::Error> {
938        use sqlx::Executor;
939        self.pool.execute(
940            r#"
941            CREATE SCHEMA IF NOT EXISTS authkestra;
942
943            CREATE TABLE IF NOT EXISTS authkestra.oauth_clients (
944                client_id VARCHAR(255) PRIMARY KEY,
945                client_secret_hash VARCHAR(255),
946                require_pkce BOOLEAN NOT NULL DEFAULT TRUE,
947                redirect_uris JSONB NOT NULL,
948                grant_types JSONB NOT NULL,
949                scopes JSONB NOT NULL,
950                allowed_audiences JSONB NOT NULL
951            );
952
953            CREATE TABLE IF NOT EXISTS authkestra.oauth_codes (
954                code VARCHAR(255) PRIMARY KEY,
955                client_id VARCHAR(255) NOT NULL REFERENCES authkestra.oauth_clients(client_id) ON DELETE CASCADE,
956                redirect_uri TEXT NOT NULL,
957                scope TEXT NOT NULL,
958                code_challenge VARCHAR(255),
959                code_challenge_method VARCHAR(10),
960                nonce VARCHAR(255),
961                identity JSONB NOT NULL,
962                expires_at TIMESTAMPTZ NOT NULL,
963                used BOOLEAN NOT NULL DEFAULT FALSE
964            );
965
966            CREATE TABLE IF NOT EXISTS authkestra.oauth_refresh_tokens (
967                token VARCHAR(255) PRIMARY KEY,
968                client_id VARCHAR(255) NOT NULL REFERENCES authkestra.oauth_clients(client_id) ON DELETE CASCADE,
969                identity JSONB NOT NULL,
970                scope TEXT NOT NULL,
971                expires_at TIMESTAMPTZ NOT NULL,
972                revoked_at TIMESTAMPTZ
973            );
974
975            CREATE TABLE IF NOT EXISTS authkestra.oauth_device_codes (
976                device_code VARCHAR(255) PRIMARY KEY,
977                user_code VARCHAR(255) UNIQUE NOT NULL,
978                client_id VARCHAR(255) NOT NULL REFERENCES authkestra.oauth_clients(client_id) ON DELETE CASCADE,
979                scope TEXT NOT NULL,
980                status JSONB NOT NULL,
981                expires_at TIMESTAMPTZ NOT NULL,
982                last_polled_at TIMESTAMPTZ
983            );
984            -- authkestra#291: RFC 9449 §11.1 DPoP proof replay tracking.
985            -- No foreign key to oauth_clients: a `jti` is client-generated
986            -- and checked before the grant is dispatched, so it is not
987            -- owned by a client row and must not be cascade-deleted with
988            -- one.
989            CREATE TABLE IF NOT EXISTS authkestra.oauth_dpop_jti (
990                jti VARCHAR(255) PRIMARY KEY,
991                expires_at TIMESTAMPTZ NOT NULL
992            );
993            "#
994        ).await?;
995
996        // authkestra#287: additive columns for RFC 9449 DPoP refresh-token
997        // key continuity and RFC 7523 private_key_jwt. Safe to run on every
998        // startup, against both a fresh install (just created above) and an
999        // existing deployment upgrading from before these columns existed —
1000        // and catalog-guarded so the steady-state startup takes no ACCESS
1001        // EXCLUSIVE lock on either table. See `ensure_postgres_column`.
1002        ensure_postgres_column(&self.pool, "oauth_refresh_tokens", "jkt", "jkt VARCHAR(255)").await?;
1003        ensure_postgres_column(&self.pool, "oauth_clients", "token_endpoint_auth_method", "token_endpoint_auth_method JSONB").await?;
1004        ensure_postgres_column(&self.pool, "oauth_clients", "jwks", "jwks JSONB").await?;
1005
1006        Ok(())
1007    },
1008    // consume_code (Postgres specific)
1009    pub(crate) async fn consume_code(conn: &mut Conn, code: &str) -> Result<Option<AuthorizationCode>, authkestra_engine::store::StoreError> {
1010        let query = "UPDATE authkestra.oauth_codes SET used = TRUE WHERE code = $1 AND used = FALSE RETURNING *";
1011        let row = sqlx::query(query)
1012            .bind(code)
1013            .fetch_optional(&mut *conn)
1014            .await
1015            .map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1016
1017        if let Some(row) = row {
1018            use sqlx::Row;
1019            let identity: sqlx::types::Json<authkestra_engine::auth::state::Identity> = row.try_get("identity").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1020            Ok(Some({
1021                let mut code = AuthorizationCode::new(
1022                row.try_get("code").unwrap_or_default(),
1023                row.try_get("client_id").unwrap_or_default(),
1024                row.try_get("redirect_uri").unwrap_or_default(),
1025                row.try_get("scope").unwrap_or_default(),
1026                identity.0,
1027                row.try_get("expires_at").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
1028                row.try_get("used").unwrap_or(true),
1029            );
1030                code.code_challenge = row.try_get("code_challenge").ok();
1031                code.code_challenge_method = row.try_get("code_challenge_method").ok();
1032                code.nonce = row.try_get("nonce").ok();
1033                code
1034            }))
1035        } else {
1036            Ok(None)
1037        }
1038    },
1039    // consume_token (Postgres specific)
1040    pub(crate) async fn consume_token(conn: &mut Conn, token: &str) -> Result<Option<RefreshToken>, authkestra_engine::store::StoreError> {
1041        let query = "DELETE FROM authkestra.oauth_refresh_tokens WHERE token = $1 AND revoked_at IS NULL RETURNING *";
1042        let row = sqlx::query(query)
1043            .bind(token)
1044            .fetch_optional(&mut *conn)
1045            .await
1046            .map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1047
1048        if let Some(row) = row {
1049            use sqlx::Row;
1050            let identity: sqlx::types::Json<authkestra_engine::auth::state::Identity> = row.try_get("identity").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1051            Ok(Some(RefreshToken::new(
1052                row.try_get("token").unwrap_or_default(),
1053                row.try_get("client_id").unwrap_or_default(),
1054                identity.0,
1055                row.try_get("scope").unwrap_or_default(),
1056                row.try_get("expires_at").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
1057                row.try_get("jkt").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
1058            )))
1059        } else {
1060            Ok(None)
1061        }
1062    },
1063    // consume_device_impl (Postgres specific)
1064    pub(crate) async fn consume_device_code(conn: &mut Conn, device_code: &str) -> Result<Option<DeviceCodeSession>, authkestra_engine::store::StoreError> {
1065        let query = "DELETE FROM authkestra.oauth_device_codes WHERE device_code = $1 RETURNING *";
1066        let row = sqlx::query(query)
1067            .bind(device_code)
1068            .fetch_optional(&mut *conn)
1069            .await
1070            .map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1071
1072        if let Some(row) = row {
1073            use sqlx::Row;
1074            let status: sqlx::types::Json<authkestra_op::device::DeviceCodeStatus> = row.try_get("status").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1075            Ok(Some({
1076                let mut session = DeviceCodeSession::new(
1077                row.try_get("device_code").unwrap_or_default(),
1078                row.try_get("user_code").unwrap_or_default(),
1079                row.try_get("client_id").unwrap_or_default(),
1080                row.try_get("scope").unwrap_or_default(),
1081                row.try_get("expires_at").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
1082                status.0,
1083            );
1084                session.last_polled_at = row.try_get("last_polled_at").ok();
1085                session
1086            }))
1087        } else {
1088            Ok(None)
1089        }
1090    },
1091    /// Atomically claim a DPoP proof's `jti` (RFC 9449 §11.1).
1092    ///
1093    /// Without this override `SqlxOpStore` inherited `OpStore`'s
1094    /// fail-closed default, which refuses *every* proof — so a
1095    /// SqlxOpStore-backed OP returned `invalid_dpop_proof` for every
1096    /// DPoP request, and the `jkt` column authkestra#287 added to
1097    /// `oauth_refresh_tokens` was unreachable through the token endpoint.
1098    ///
1099    /// A single statement, not `SELECT`-then-`INSERT`: the TOCTOU window in
1100    /// a two-call check is exactly the replay this exists to prevent, since
1101    /// two concurrent presentations of one captured proof would both
1102    /// observe "not yet seen".
1103    ///
1104    /// An already-expired row is *reclaimable*: a `jti` past its window can
1105    /// no longer be usefully replayed, because `verify_dpop_proof` fails it
1106    /// on freshness first.
1107    pub(crate) async fn check_and_record_dpop_jti(
1108        conn: &mut Conn,
1109        jti: &str,
1110        expires_at: chrono::DateTime<chrono::Utc>,
1111    ) -> Result<bool, authkestra_engine::store::StoreError> {
1112        // The qualified `oauth_dpop_jti.expires_at` in the WHERE clause
1113        // reads the *pre-update* row, so this claims the `jti` only when it
1114        // is absent (the INSERT wins) or already expired.
1115        let res = sqlx::query(
1116            "INSERT INTO authkestra.oauth_dpop_jti (jti, expires_at) VALUES ($1, $2) \
1117             ON CONFLICT (jti) DO UPDATE SET expires_at = $2 \
1118             WHERE oauth_dpop_jti.expires_at <= $3",
1119        )
1120        .bind(jti)
1121        .bind(expires_at)
1122        .bind(chrono::Utc::now())
1123        .execute(&mut *conn)
1124        .await
1125        .map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1126
1127        Ok(res.rows_affected() > 0)
1128    }
1129}
1130
1131// Generate the SQLite implementation
1132impl_opstore_sql! {
1133    sqlx::Sqlite,
1134    "sqlite",
1135    sqlite_queries,
1136    |_| "?".to_string(),
1137    "authkestra_",
1138    /// Run necessary database migrations to set up schema and tables.
1139    ///
1140    /// See the Postgres impl's identical doc comment for why this is
1141    /// deliberately not `sqlx::migrate!`. SQLite has no native `ADD COLUMN
1142    /// IF NOT EXISTS`, so the three new columns go through
1143    /// `ensure_sqlite_column`'s `pragma_table_info` introspection instead.
1144    pub async fn migrate(&self) -> Result<(), sqlx::Error> {
1145        use sqlx::Executor;
1146        self.pool.execute(
1147            r#"
1148            CREATE TABLE IF NOT EXISTS authkestra_oauth_clients (
1149                client_id TEXT PRIMARY KEY,
1150                client_secret_hash TEXT,
1151                require_pkce BOOLEAN NOT NULL DEFAULT 1,
1152                redirect_uris TEXT NOT NULL,
1153                grant_types TEXT NOT NULL,
1154                scopes TEXT NOT NULL,
1155                allowed_audiences TEXT NOT NULL
1156            );
1157
1158            CREATE TABLE IF NOT EXISTS authkestra_oauth_codes (
1159                code TEXT PRIMARY KEY,
1160                client_id TEXT NOT NULL REFERENCES authkestra_oauth_clients(client_id) ON DELETE CASCADE,
1161                redirect_uri TEXT NOT NULL,
1162                scope TEXT NOT NULL,
1163                code_challenge TEXT,
1164                code_challenge_method TEXT,
1165                nonce TEXT,
1166                identity TEXT NOT NULL,
1167                expires_at DATETIME NOT NULL,
1168                used BOOLEAN NOT NULL DEFAULT 0
1169            );
1170
1171            CREATE TABLE IF NOT EXISTS authkestra_oauth_refresh_tokens (
1172                token TEXT PRIMARY KEY,
1173                client_id TEXT NOT NULL REFERENCES authkestra_oauth_clients(client_id) ON DELETE CASCADE,
1174                identity TEXT NOT NULL,
1175                scope TEXT NOT NULL,
1176                expires_at DATETIME NOT NULL,
1177                revoked_at DATETIME
1178            );
1179
1180            CREATE TABLE IF NOT EXISTS authkestra_oauth_device_codes (
1181                device_code TEXT PRIMARY KEY,
1182                user_code TEXT UNIQUE NOT NULL,
1183                client_id TEXT NOT NULL REFERENCES authkestra_oauth_clients(client_id) ON DELETE CASCADE,
1184                scope TEXT NOT NULL,
1185                status TEXT NOT NULL,
1186                expires_at DATETIME NOT NULL,
1187                last_polled_at DATETIME
1188            );
1189
1190            -- authkestra#291: RFC 9449 §11.1 DPoP proof replay tracking.
1191            -- See the Postgres migration for why there is no client_id FK.
1192            CREATE TABLE IF NOT EXISTS authkestra_oauth_dpop_jti (
1193                jti TEXT PRIMARY KEY,
1194                expires_at DATETIME NOT NULL
1195            );
1196            "#
1197        ).await?;
1198
1199        // authkestra#287: additive columns for RFC 9449 DPoP refresh-token
1200        // key continuity and RFC 7523 private_key_jwt. Safe to run on every
1201        // startup, against both a fresh install (just created above) and an
1202        // existing deployment upgrading from before these columns existed.
1203        ensure_sqlite_column(&self.pool, "authkestra_oauth_refresh_tokens", "jkt", "jkt TEXT").await?;
1204        ensure_sqlite_column(&self.pool, "authkestra_oauth_clients", "token_endpoint_auth_method", "token_endpoint_auth_method TEXT").await?;
1205        ensure_sqlite_column(&self.pool, "authkestra_oauth_clients", "jwks", "jwks TEXT").await?;
1206
1207        Ok(())
1208    },
1209    // consume_code (SQLite specific)
1210    pub(crate) async fn consume_code(conn: &mut Conn, code: &str) -> Result<Option<AuthorizationCode>, authkestra_engine::store::StoreError> {
1211        let query = "UPDATE authkestra_oauth_codes SET used = TRUE WHERE code = ? AND used = FALSE RETURNING *";
1212        let row = sqlx::query(query)
1213            .bind(code)
1214            .fetch_optional(&mut *conn)
1215            .await
1216            .map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1217
1218        if let Some(row) = row {
1219            use sqlx::Row;
1220            let identity: sqlx::types::Json<authkestra_engine::auth::state::Identity> = row.try_get("identity").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1221            Ok(Some({
1222                let mut code = AuthorizationCode::new(
1223                row.try_get("code").unwrap_or_default(),
1224                row.try_get("client_id").unwrap_or_default(),
1225                row.try_get("redirect_uri").unwrap_or_default(),
1226                row.try_get("scope").unwrap_or_default(),
1227                identity.0,
1228                row.try_get("expires_at").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
1229                row.try_get("used").unwrap_or(true),
1230            );
1231                code.code_challenge = row.try_get("code_challenge").ok();
1232                code.code_challenge_method = row.try_get("code_challenge_method").ok();
1233                code.nonce = row.try_get("nonce").ok();
1234                code
1235            }))
1236        } else {
1237            Ok(None)
1238        }
1239    },
1240    // consume_token (SQLite specific)
1241    pub(crate) async fn consume_token(conn: &mut Conn, token: &str) -> Result<Option<RefreshToken>, authkestra_engine::store::StoreError> {
1242        let query = "DELETE FROM authkestra_oauth_refresh_tokens WHERE token = ? AND revoked_at IS NULL RETURNING *";
1243        let row = sqlx::query(query)
1244            .bind(token)
1245            .fetch_optional(&mut *conn)
1246            .await
1247            .map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1248
1249        if let Some(row) = row {
1250            use sqlx::Row;
1251            let identity: sqlx::types::Json<authkestra_engine::auth::state::Identity> = row.try_get("identity").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1252            Ok(Some(RefreshToken::new(
1253                row.try_get("token").unwrap_or_default(),
1254                row.try_get("client_id").unwrap_or_default(),
1255                identity.0,
1256                row.try_get("scope").unwrap_or_default(),
1257                row.try_get("expires_at").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
1258                row.try_get("jkt").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
1259            )))
1260        } else {
1261            Ok(None)
1262        }
1263    },
1264    // consume_device_impl (SQLite specific)
1265    pub(crate) async fn consume_device_code(conn: &mut Conn, device_code: &str) -> Result<Option<DeviceCodeSession>, authkestra_engine::store::StoreError> {
1266        let query = "DELETE FROM authkestra_oauth_device_codes WHERE device_code = ? RETURNING *";
1267        let row = sqlx::query(query)
1268            .bind(device_code)
1269            .fetch_optional(&mut *conn)
1270            .await
1271            .map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1272
1273        if let Some(row) = row {
1274            use sqlx::Row;
1275            let status: sqlx::types::Json<authkestra_op::device::DeviceCodeStatus> = row.try_get("status").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1276            Ok(Some({
1277                let mut session = DeviceCodeSession::new(
1278                row.try_get("device_code").unwrap_or_default(),
1279                row.try_get("user_code").unwrap_or_default(),
1280                row.try_get("client_id").unwrap_or_default(),
1281                row.try_get("scope").unwrap_or_default(),
1282                row.try_get("expires_at").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
1283                status.0,
1284            );
1285                session.last_polled_at = row.try_get("last_polled_at").ok();
1286                session
1287            }))
1288        } else {
1289            Ok(None)
1290        }
1291    },
1292    /// Atomically claim a DPoP proof's `jti` (RFC 9449 §11.1).
1293    ///
1294    /// Without this override `SqlxOpStore` inherited `OpStore`'s
1295    /// fail-closed default, which refuses *every* proof — so a
1296    /// SqlxOpStore-backed OP returned `invalid_dpop_proof` for every
1297    /// DPoP request, and the `jkt` column authkestra#287 added to
1298    /// `oauth_refresh_tokens` was unreachable through the token endpoint.
1299    ///
1300    /// A single statement, not `SELECT`-then-`INSERT`: the TOCTOU window in
1301    /// a two-call check is exactly the replay this exists to prevent, since
1302    /// two concurrent presentations of one captured proof would both
1303    /// observe "not yet seen".
1304    ///
1305    /// An already-expired row is *reclaimable*: a `jti` past its window can
1306    /// no longer be usefully replayed, because `verify_dpop_proof` fails it
1307    /// on freshness first.
1308    pub(crate) async fn check_and_record_dpop_jti(
1309        conn: &mut Conn,
1310        jti: &str,
1311        expires_at: chrono::DateTime<chrono::Utc>,
1312    ) -> Result<bool, authkestra_engine::store::StoreError> {
1313        // See the Postgres implementation; SQLite's upsert has the same
1314        // pre-update read semantics for the qualified column.
1315        let res = sqlx::query(
1316            "INSERT INTO authkestra_oauth_dpop_jti (jti, expires_at) VALUES (?1, ?2) \
1317             ON CONFLICT (jti) DO UPDATE SET expires_at = ?2 \
1318             WHERE authkestra_oauth_dpop_jti.expires_at <= ?3",
1319        )
1320        .bind(jti)
1321        .bind(expires_at)
1322        .bind(chrono::Utc::now())
1323        .execute(&mut *conn)
1324        .await
1325        .map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1326
1327        Ok(res.rows_affected() > 0)
1328    }
1329}
1330
1331// Generate the MySQL implementation
1332impl_opstore_sql! {
1333    sqlx::MySql,
1334    "mysql",
1335    mysql_queries,
1336    |_| "?".to_string(),
1337    "authkestra_",
1338    /// Run necessary database migrations to set up schema and tables.
1339    ///
1340    /// See the Postgres impl's identical doc comment for why this is
1341    /// deliberately not `sqlx::migrate!`. MySQL has no `ADD COLUMN IF NOT
1342    /// EXISTS` across commonly-deployed versions, so the three new columns
1343    /// go through `ensure_mysql_column`'s `information_schema`
1344    /// introspection instead.
1345    pub async fn migrate(&self) -> Result<(), sqlx::Error> {
1346        use sqlx::Executor;
1347        self.pool.execute(
1348            r#"
1349            CREATE TABLE IF NOT EXISTS authkestra_oauth_clients (
1350                client_id VARCHAR(255) PRIMARY KEY,
1351                client_secret_hash VARCHAR(255),
1352                require_pkce BOOLEAN NOT NULL DEFAULT TRUE,
1353                redirect_uris JSON NOT NULL,
1354                grant_types JSON NOT NULL,
1355                scopes JSON NOT NULL,
1356                allowed_audiences JSON NOT NULL
1357            );
1358
1359            CREATE TABLE IF NOT EXISTS authkestra_oauth_codes (
1360                code VARCHAR(255) PRIMARY KEY,
1361                client_id VARCHAR(255) NOT NULL,
1362                redirect_uri TEXT NOT NULL,
1363                scope TEXT NOT NULL,
1364                code_challenge VARCHAR(255),
1365                code_challenge_method VARCHAR(10),
1366                nonce VARCHAR(255),
1367                identity JSON NOT NULL,
1368                expires_at DATETIME NOT NULL,
1369                used BOOLEAN NOT NULL DEFAULT FALSE,
1370                FOREIGN KEY (client_id) REFERENCES authkestra_oauth_clients(client_id) ON DELETE CASCADE
1371            );
1372
1373            CREATE TABLE IF NOT EXISTS authkestra_oauth_refresh_tokens (
1374                token VARCHAR(255) PRIMARY KEY,
1375                client_id VARCHAR(255) NOT NULL,
1376                identity JSON NOT NULL,
1377                scope TEXT NOT NULL,
1378                expires_at DATETIME NOT NULL,
1379                revoked_at DATETIME,
1380                FOREIGN KEY (client_id) REFERENCES authkestra_oauth_clients(client_id) ON DELETE CASCADE
1381            );
1382
1383            CREATE TABLE IF NOT EXISTS authkestra_oauth_device_codes (
1384                device_code VARCHAR(255) PRIMARY KEY,
1385                user_code VARCHAR(255) UNIQUE NOT NULL,
1386                client_id VARCHAR(255) NOT NULL,
1387                scope TEXT NOT NULL,
1388                status JSON NOT NULL,
1389                expires_at DATETIME NOT NULL,
1390                last_polled_at DATETIME,
1391                FOREIGN KEY (client_id) REFERENCES authkestra_oauth_clients(client_id) ON DELETE CASCADE
1392            );
1393
1394            -- authkestra#291: RFC 9449 §11.1 DPoP proof replay tracking.
1395            -- See the Postgres migration for why there is no client_id FK.
1396            -- DATETIME(3) rather than DATETIME: MySQL rounds a DATETIME to
1397            -- whole seconds, which would let a `jti` stay blocked up to ~1s
1398            -- past its window and, worse, make the expired-row reclaim
1399            -- below compare against a rounded value.
1400            CREATE TABLE IF NOT EXISTS authkestra_oauth_dpop_jti (
1401                jti VARCHAR(255) PRIMARY KEY,
1402                expires_at DATETIME(3) NOT NULL
1403            );
1404            "#
1405        ).await?;
1406
1407        // authkestra#287: additive columns for RFC 9449 DPoP refresh-token
1408        // key continuity and RFC 7523 private_key_jwt. Safe to run on every
1409        // startup, against both a fresh install (just created above) and an
1410        // existing deployment upgrading from before these columns existed.
1411        ensure_mysql_column(&self.pool, "authkestra_oauth_refresh_tokens", "jkt", "jkt VARCHAR(255)").await?;
1412        ensure_mysql_column(&self.pool, "authkestra_oauth_clients", "token_endpoint_auth_method", "token_endpoint_auth_method JSON").await?;
1413        ensure_mysql_column(&self.pool, "authkestra_oauth_clients", "jwks", "jwks JSON").await?;
1414
1415        Ok(())
1416    },
1417    // consume_code (MySQL specific - needs transaction and FOR UPDATE since no RETURNING)
1418    pub(crate) async fn consume_code(conn: &mut Conn, code: &str) -> Result<Option<AuthorizationCode>, authkestra_engine::store::StoreError> {
1419        let mut tx = conn.begin().await.map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1420
1421        let select_query = "SELECT * FROM authkestra_oauth_codes WHERE code = ? AND used = FALSE FOR UPDATE";
1422        let row = sqlx::query(select_query)
1423            .bind(code)
1424            .fetch_optional(&mut *tx)
1425            .await
1426            .map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1427
1428        if let Some(row) = row {
1429            let update_query = "UPDATE authkestra_oauth_codes SET used = TRUE WHERE code = ?";
1430            sqlx::query(update_query)
1431                .bind(code)
1432                .execute(&mut *tx)
1433                .await
1434                .map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1435
1436            tx.commit().await.map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1437
1438            use sqlx::Row;
1439            let identity: sqlx::types::Json<authkestra_engine::auth::state::Identity> = row.try_get("identity").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1440            Ok(Some({
1441                let mut code = AuthorizationCode::new(
1442                row.try_get("code").unwrap_or_default(),
1443                row.try_get("client_id").unwrap_or_default(),
1444                row.try_get("redirect_uri").unwrap_or_default(),
1445                row.try_get("scope").unwrap_or_default(),
1446                identity.0,
1447                row.try_get("expires_at").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
1448                row.try_get("used").unwrap_or(true),
1449            );
1450                code.code_challenge = row.try_get("code_challenge").ok();
1451                code.code_challenge_method = row.try_get("code_challenge_method").ok();
1452                code.nonce = row.try_get("nonce").ok();
1453                code
1454            }))
1455        } else {
1456            tx.rollback().await.map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1457            Ok(None)
1458        }
1459    },
1460    // consume_token (MySQL specific)
1461    pub(crate) async fn consume_token(conn: &mut Conn, token: &str) -> Result<Option<RefreshToken>, authkestra_engine::store::StoreError> {
1462        let mut tx = conn.begin().await.map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1463
1464        let select_query = "SELECT * FROM authkestra_oauth_refresh_tokens WHERE token = ? AND revoked_at IS NULL FOR UPDATE";
1465        let row = sqlx::query(select_query)
1466            .bind(token)
1467            .fetch_optional(&mut *tx)
1468            .await
1469            .map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1470
1471        if let Some(row) = row {
1472            let delete_query = "DELETE FROM authkestra_oauth_refresh_tokens WHERE token = ?";
1473            sqlx::query(delete_query)
1474                .bind(token)
1475                .execute(&mut *tx)
1476                .await
1477                .map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1478
1479            tx.commit().await.map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1480
1481            use sqlx::Row;
1482            let identity: sqlx::types::Json<authkestra_engine::auth::state::Identity> = row.try_get("identity").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1483            Ok(Some(RefreshToken::new(
1484                row.try_get("token").unwrap_or_default(),
1485                row.try_get("client_id").unwrap_or_default(),
1486                identity.0,
1487                row.try_get("scope").unwrap_or_default(),
1488                row.try_get("expires_at").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
1489                row.try_get("jkt").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
1490            )))
1491        } else {
1492            tx.rollback().await.map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1493            Ok(None)
1494        }
1495    },
1496    // consume_device_impl (MySQL specific)
1497    pub(crate) async fn consume_device_code(conn: &mut Conn, device_code: &str) -> Result<Option<DeviceCodeSession>, authkestra_engine::store::StoreError> {
1498        let mut tx = conn.begin().await.map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1499
1500        let select_query = "SELECT * FROM authkestra_oauth_device_codes WHERE device_code = ? FOR UPDATE";
1501        let row = sqlx::query(select_query)
1502            .bind(device_code)
1503            .fetch_optional(&mut *tx)
1504            .await
1505            .map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1506
1507        if let Some(row) = row {
1508            let delete_query = "DELETE FROM authkestra_oauth_device_codes WHERE device_code = ?";
1509            sqlx::query(delete_query)
1510                .bind(device_code)
1511                .execute(&mut *tx)
1512                .await
1513                .map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1514
1515            tx.commit().await.map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1516
1517            use sqlx::Row;
1518            let status: sqlx::types::Json<authkestra_op::device::DeviceCodeStatus> = row.try_get("status").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1519            Ok(Some({
1520                let mut session = DeviceCodeSession::new(
1521                row.try_get("device_code").unwrap_or_default(),
1522                row.try_get("user_code").unwrap_or_default(),
1523                row.try_get("client_id").unwrap_or_default(),
1524                row.try_get("scope").unwrap_or_default(),
1525                row.try_get("expires_at").map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?,
1526                status.0,
1527            );
1528                session.last_polled_at = row.try_get("last_polled_at").ok();
1529                session
1530            }))
1531        } else {
1532            tx.rollback().await.map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1533            Ok(None)
1534        }
1535    },
1536    /// Atomically claim a DPoP proof's `jti` (RFC 9449 §11.1).
1537    ///
1538    /// Without this override `SqlxOpStore` inherited `OpStore`'s
1539    /// fail-closed default, which refuses *every* proof — so a
1540    /// SqlxOpStore-backed OP returned `invalid_dpop_proof` for every
1541    /// DPoP request, and the `jkt` column authkestra#287 added to
1542    /// `oauth_refresh_tokens` was unreachable through the token endpoint.
1543    ///
1544    /// A single statement, not `SELECT`-then-`INSERT`: the TOCTOU window in
1545    /// a two-call check is exactly the replay this exists to prevent, since
1546    /// two concurrent presentations of one captured proof would both
1547    /// observe "not yet seen".
1548    ///
1549    /// An already-expired row is *reclaimable*: a `jti` past its window can
1550    /// no longer be usefully replayed, because `verify_dpop_proof` fails it
1551    /// on freshness first.
1552    ///
1553    /// Two statements rather than one, and deliberately **not** wrapped in a
1554    /// transaction with `SELECT ... FOR UPDATE`: on a row that does not yet
1555    /// exist that pattern takes a gap lock, and two concurrent claims of the
1556    /// same `jti` then deadlock under MySQL's default REPEATABLE READ
1557    /// (authkestra#277 hit exactly this). Each statement below is
1558    /// individually atomic, so no transaction is needed.
1559    pub(crate) async fn check_and_record_dpop_jti(
1560        conn: &mut Conn,
1561        jti: &str,
1562        expires_at: chrono::DateTime<chrono::Utc>,
1563    ) -> Result<bool, authkestra_engine::store::StoreError> {
1564        let inserted = sqlx::query(
1565            "INSERT IGNORE INTO authkestra_oauth_dpop_jti (jti, expires_at) VALUES (?, ?)",
1566        )
1567        .bind(jti)
1568        .bind(expires_at)
1569        .execute(&mut *conn)
1570        .await
1571        .map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1572
1573        if inserted.rows_affected() == 1 {
1574            return Ok(true);
1575        }
1576
1577        // The `jti` is present. Reclaim it only if its window has passed.
1578        // `rows_affected` counts *changed* rows on MySQL (sqlx does not set
1579        // CLIENT_FOUND_ROWS), which is safe here only because the new
1580        // `expires_at` is always later than the expired one it replaces —
1581        // so a matching row always changes.
1582        let reclaimed = sqlx::query(
1583            "UPDATE authkestra_oauth_dpop_jti SET expires_at = ? \
1584             WHERE jti = ? AND expires_at <= ?",
1585        )
1586        .bind(expires_at)
1587        .bind(jti)
1588        .bind(chrono::Utc::now())
1589        .execute(&mut *conn)
1590        .await
1591        .map_err(|e| authkestra_engine::store::StoreError::Internal(format!("db error: {e}")))?;
1592
1593        Ok(reclaimed.rows_affected() > 0)
1594    }
1595}
1596
1597#[cfg(all(test, feature = "postgres"))]
1598mod postgres_tests {
1599    use super::*;
1600    use authkestra_op::code::{AuthorizationCode, AuthorizationCodeStore};
1601    use chrono::{Duration, Utc};
1602    use sqlx::postgres::PgPoolOptions;
1603    use testcontainers::{runners::AsyncRunner, ContainerAsync, ImageExt};
1604    use testcontainers_modules::postgres::Postgres;
1605
1606    async fn setup_db() -> (SqlxOpStore<sqlx::Postgres>, ContainerAsync<Postgres>) {
1607        let container = Postgres::default()
1608            .with_env_var("POSTGRES_PASSWORD", "postgres")
1609            .with_env_var("POSTGRES_USER", "postgres")
1610            .with_env_var("POSTGRES_DB", "postgres")
1611            .start()
1612            .await
1613            .unwrap();
1614        let port = container.get_host_port_ipv4(5432).await.unwrap();
1615        let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres");
1616
1617        let pool = PgPoolOptions::new()
1618            .max_connections(5)
1619            .connect(&url)
1620            .await
1621            .unwrap();
1622
1623        let store = SqlxOpStore::<sqlx::Postgres>::new(pool);
1624        store.migrate().await.unwrap();
1625
1626        (store, container)
1627    }
1628
1629    #[tokio::test]
1630    async fn test_postgres_authorization_code_cascading_delete() {
1631        let (mut store, _c) = setup_db().await;
1632
1633        // Manually insert a client
1634        sqlx::query(
1635            "INSERT INTO authkestra.oauth_clients (client_id, client_secret_hash, require_pkce, redirect_uris, grant_types, scopes, allowed_audiences) 
1636             VALUES ($1, $2, $3, $4, $5, $6, $7)"
1637        )
1638        .bind("test_client")
1639        .bind("hash")
1640        .bind(true)
1641        .bind(sqlx::types::Json(vec!["http://localhost/cb"]))
1642        .bind(sqlx::types::Json(vec!["authorization_code"]))
1643        .bind(sqlx::types::Json(vec!["openid"]))
1644        .bind(sqlx::types::Json(vec!["aud"]))
1645        .execute(&store.pool)
1646        .await
1647        .unwrap();
1648
1649        let code = AuthorizationCode::new(
1650            "test_code_123".to_string(),
1651            "test_client".to_string(),
1652            "http://localhost/cb".to_string(),
1653            "openid".to_string(),
1654            authkestra_engine::auth::state::Identity {
1655                provider_id: "local".to_string(),
1656                external_id: "user_1".to_string(),
1657                email: None,
1658                username: None,
1659                attributes: std::collections::HashMap::new(),
1660            },
1661            Utc::now() + Duration::try_minutes(10).unwrap(),
1662            false,
1663        );
1664
1665        store.store_code(code.clone()).await.unwrap();
1666
1667        // Consume it to verify it exists
1668        let consumed = store.consume_code("test_code_123").await.unwrap();
1669        assert!(consumed.is_some());
1670        assert_eq!(consumed.unwrap().client_id, "test_client");
1671
1672        // Test cascade delete
1673        // Re-insert the code
1674        let mut code2 = code;
1675        code2.code = "test_code_456".to_string();
1676        store.store_code(code2.clone()).await.unwrap();
1677
1678        // Delete the client
1679        sqlx::query("DELETE FROM authkestra.oauth_clients WHERE client_id = 'test_client'")
1680            .execute(&store.pool)
1681            .await
1682            .unwrap();
1683
1684        // Ensure the code is also deleted due to CASCADE
1685        let count: (i64,) = sqlx::query_as(
1686            "SELECT COUNT(*) FROM authkestra.oauth_codes WHERE code = 'test_code_456'",
1687        )
1688        .fetch_one(&store.pool)
1689        .await
1690        .unwrap();
1691
1692        assert_eq!(count.0, 0);
1693    }
1694
1695    #[tokio::test]
1696    async fn test_postgres_concurrency() {
1697        let (mut store, _c) = setup_db().await;
1698
1699        sqlx::query(
1700            "INSERT INTO authkestra.oauth_clients (client_id, client_secret_hash, require_pkce, redirect_uris, grant_types, scopes, allowed_audiences) 
1701             VALUES ($1, $2, $3, $4, $5, $6, $7)"
1702        )
1703        .bind("concurrency_client")
1704        .bind("hash")
1705        .bind(true)
1706        .bind(sqlx::types::Json(vec!["http://localhost/cb"]))
1707        .bind(sqlx::types::Json(vec!["authorization_code"]))
1708        .bind(sqlx::types::Json(vec!["openid"]))
1709        .bind(sqlx::types::Json(vec!["aud"]))
1710        .execute(&store.pool)
1711        .await
1712        .unwrap();
1713
1714        let code = AuthorizationCode::new(
1715            "concurrent_code".to_string(),
1716            "concurrency_client".to_string(),
1717            "http://localhost/cb".to_string(),
1718            "openid".to_string(),
1719            authkestra_engine::auth::state::Identity {
1720                provider_id: "local".to_string(),
1721                external_id: "user_1".to_string(),
1722                email: None,
1723                username: None,
1724                attributes: std::collections::HashMap::new(),
1725            },
1726            Utc::now() + Duration::try_minutes(10).unwrap(),
1727            false,
1728        );
1729        store.store_code(code.clone()).await.unwrap();
1730
1731        let mut handles = vec![];
1732        let store_arc = store.clone();
1733
1734        // Spawn 10 simultaneous consumers
1735        for _ in 0..10 {
1736            let mut s = store_arc.clone();
1737            handles.push(tokio::spawn(async move {
1738                s.consume_code("concurrent_code").await.unwrap()
1739            }));
1740        }
1741
1742        let mut successes = 0;
1743        let mut failures = 0;
1744        for h in handles {
1745            let res = h.await.unwrap();
1746            if res.is_some() {
1747                successes += 1;
1748            } else {
1749                failures += 1;
1750            }
1751        }
1752
1753        assert_eq!(successes, 1);
1754        assert_eq!(failures, 9);
1755    }
1756
1757    fn test_identity() -> authkestra_engine::auth::state::Identity {
1758        authkestra_engine::auth::state::Identity {
1759            provider_id: "local".to_string(),
1760            external_id: "user_1".to_string(),
1761            email: None,
1762            username: None,
1763            attributes: std::collections::HashMap::new(),
1764        }
1765    }
1766
1767    /// authkestra#287: `jkt` (RFC 9449 DPoP refresh-token continuity) and
1768    /// `token_endpoint_auth_method`/`jwks` (RFC 7523 private_key_jwt) must
1769    /// actually round-trip through a fresh install, not just exist as
1770    /// columns nothing reads or writes.
1771    #[tokio::test]
1772    async fn test_postgres_fresh_install_persists_jkt_and_client_auth_fields() {
1773        let (mut store, _c) = setup_db().await;
1774
1775        sqlx::query(
1776            "INSERT INTO authkestra.oauth_clients
1777             (client_id, client_secret_hash, require_pkce, redirect_uris, grant_types, scopes, allowed_audiences, token_endpoint_auth_method, jwks)
1778             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"
1779        )
1780        .bind("auth287_client")
1781        .bind("hash")
1782        .bind(true)
1783        .bind(sqlx::types::Json(vec!["http://localhost/cb"]))
1784        .bind(sqlx::types::Json(vec!["authorization_code"]))
1785        .bind(sqlx::types::Json(vec!["openid"]))
1786        .bind(sqlx::types::Json(vec!["aud"]))
1787        .bind(sqlx::types::Json(TokenEndpointAuthMethod::PrivateKeyJwt))
1788        .bind(sqlx::types::Json(serde_json::json!({"keys": []})))
1789        .execute(&store.pool)
1790        .await
1791        .unwrap();
1792
1793        let client = store
1794            .find_client("auth287_client")
1795            .await
1796            .unwrap()
1797            .expect("client must be found");
1798        assert_eq!(
1799            client.token_endpoint_auth_method,
1800            Some(TokenEndpointAuthMethod::PrivateKeyJwt)
1801        );
1802        assert_eq!(client.jwks, Some(serde_json::json!({"keys": []})));
1803
1804        let rt = RefreshToken::new(
1805            "rt-287".to_string(),
1806            "auth287_client".to_string(),
1807            test_identity(),
1808            "openid".to_string(),
1809            Utc::now() + Duration::try_days(1).unwrap(),
1810            Some("expected-jkt-thumbprint".to_string()),
1811        );
1812        store.store_token(rt).await.unwrap();
1813
1814        let fetched = store
1815            .get_token("rt-287")
1816            .await
1817            .unwrap()
1818            .expect("token must be found");
1819        assert_eq!(fetched.jkt, Some("expected-jkt-thumbprint".to_string()));
1820
1821        let consumed = store
1822            .consume_token("rt-287")
1823            .await
1824            .unwrap()
1825            .expect("token must be consumable");
1826        assert_eq!(consumed.jkt, Some("expected-jkt-thumbprint".to_string()));
1827    }
1828
1829    /// The actual point of authkestra#287: a deployment that already ran
1830    /// the *old* `migrate()` (before `jkt`/`token_endpoint_auth_method`/
1831    /// `jwks` existed) must upgrade safely when it starts running the new
1832    /// code — no "table already exists" failure, no data loss for
1833    /// pre-existing rows, and the new columns must actually work
1834    /// afterwards. This is the scenario `CREATE TABLE IF NOT EXISTS`
1835    /// alone could never handle.
1836    #[tokio::test]
1837    async fn test_postgres_migration_upgrades_a_pre_existing_deployment_without_the_new_columns() {
1838        let container = Postgres::default()
1839            .with_env_var("POSTGRES_PASSWORD", "postgres")
1840            .with_env_var("POSTGRES_USER", "postgres")
1841            .with_env_var("POSTGRES_DB", "postgres")
1842            .start()
1843            .await
1844            .unwrap();
1845        let port = container.get_host_port_ipv4(5432).await.unwrap();
1846        let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres");
1847        let pool = PgPoolOptions::new()
1848            .max_connections(5)
1849            .connect(&url)
1850            .await
1851            .unwrap();
1852
1853        // The pre-#287 schema, created directly — bypassing
1854        // `store.migrate()` entirely, exactly like an existing deployment
1855        // that ran the old code would already have. Uses the raw simple
1856        // query protocol (`Executor::execute`, same as the old `migrate()`
1857        // this replaces used), since multiple `;`-separated statements
1858        // aren't accepted by `sqlx::query(...).execute(...)`'s prepared-
1859        // statement protocol.
1860        use sqlx::Executor;
1861        pool.execute(
1862            "CREATE SCHEMA IF NOT EXISTS authkestra;
1863             CREATE TABLE authkestra.oauth_clients (
1864                client_id VARCHAR(255) PRIMARY KEY,
1865                client_secret_hash VARCHAR(255),
1866                require_pkce BOOLEAN NOT NULL DEFAULT TRUE,
1867                redirect_uris JSONB NOT NULL,
1868                grant_types JSONB NOT NULL,
1869                scopes JSONB NOT NULL,
1870                allowed_audiences JSONB NOT NULL
1871            );
1872            CREATE TABLE authkestra.oauth_refresh_tokens (
1873                token VARCHAR(255) PRIMARY KEY,
1874                client_id VARCHAR(255) NOT NULL REFERENCES authkestra.oauth_clients(client_id) ON DELETE CASCADE,
1875                identity JSONB NOT NULL,
1876                scope TEXT NOT NULL,
1877                expires_at TIMESTAMPTZ NOT NULL,
1878                revoked_at TIMESTAMPTZ
1879            );",
1880        )
1881        .await
1882        .unwrap();
1883
1884        // A client registered under the old schema, before this
1885        // deployment ever knew about these fields.
1886        sqlx::query(
1887            "INSERT INTO authkestra.oauth_clients
1888             (client_id, client_secret_hash, require_pkce, redirect_uris, grant_types, scopes, allowed_audiences)
1889             VALUES ($1, $2, $3, $4, $5, $6, $7)"
1890        )
1891        .bind("pre_existing_client")
1892        .bind("hash")
1893        .bind(true)
1894        .bind(sqlx::types::Json(vec!["http://localhost/cb"]))
1895        .bind(sqlx::types::Json(vec!["authorization_code"]))
1896        .bind(sqlx::types::Json(vec!["openid"]))
1897        .bind(sqlx::types::Json(vec!["aud"]))
1898        .execute(&pool)
1899        .await
1900        .unwrap();
1901
1902        let mut store = SqlxOpStore::<sqlx::Postgres>::new(pool);
1903
1904        store
1905            .migrate()
1906            .await
1907            .expect("migrating an existing pre-authkestra#287 database must succeed");
1908
1909        let client = store
1910            .find_client("pre_existing_client")
1911            .await
1912            .unwrap()
1913            .expect("the pre-existing client must survive the migration");
1914        assert_eq!(client.token_endpoint_auth_method, None);
1915        assert_eq!(client.jwks, None);
1916
1917        let rt = RefreshToken::new(
1918            "rt-upgrade".to_string(),
1919            "pre_existing_client".to_string(),
1920            test_identity(),
1921            "openid".to_string(),
1922            Utc::now() + Duration::try_days(1).unwrap(),
1923            Some("post-upgrade-jkt".to_string()),
1924        );
1925        store
1926            .store_token(rt)
1927            .await
1928            .expect("storing a DPoP-bound refresh token must work after the upgrade");
1929        let fetched = store
1930            .get_token("rt-upgrade")
1931            .await
1932            .unwrap()
1933            .expect("token must be found");
1934        assert_eq!(fetched.jkt, Some("post-upgrade-jkt".to_string()));
1935    }
1936
1937    /// authkestra#291: `SqlxOpStore` used to inherit the fail-closed
1938    /// `NoDpopReplayStore` default, refusing every DPoP proof.
1939    #[tokio::test]
1940    async fn test_postgres_dpop_jti_is_claimed_once_and_replay_is_refused() {
1941        use authkestra_op::store::OpStore;
1942        let (mut store, _c) = setup_db().await;
1943        let expires_at = Utc::now() + Duration::seconds(60);
1944
1945        assert!(store
1946            .check_and_record_dpop_jti("jti-291", expires_at)
1947            .await
1948            .unwrap());
1949        assert!(
1950            !store
1951                .check_and_record_dpop_jti("jti-291", expires_at)
1952                .await
1953                .unwrap(),
1954            "replaying a still-fresh jti must be refused"
1955        );
1956    }
1957
1958    #[tokio::test]
1959    async fn test_postgres_dpop_jti_is_reclaimable_once_expired() {
1960        use authkestra_op::store::OpStore;
1961        let (mut store, _c) = setup_db().await;
1962
1963        assert!(store
1964            .check_and_record_dpop_jti("jti-expired", Utc::now() - Duration::seconds(5))
1965            .await
1966            .unwrap());
1967        assert!(
1968            store
1969                .check_and_record_dpop_jti("jti-expired", Utc::now() + Duration::seconds(60))
1970                .await
1971                .unwrap(),
1972            "an expired jti must be reclaimable"
1973        );
1974    }
1975
1976    /// Guards both the TOCTOU window and, on MySQL, the gap-lock deadlock
1977    /// that a `SELECT ... FOR UPDATE` transaction would hit on a
1978    /// not-yet-existing row under the default REPEATABLE READ.
1979    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1980    async fn test_postgres_dpop_jti_claim_is_atomic_under_concurrency() {
1981        use authkestra_op::store::OpStore;
1982        let (store, _c) = setup_db().await;
1983        let expires_at = Utc::now() + Duration::seconds(60);
1984
1985        let mut set = tokio::task::JoinSet::new();
1986        for _ in 0..16 {
1987            let mut store = store.clone();
1988            set.spawn(async move {
1989                store
1990                    .check_and_record_dpop_jti("jti-race", expires_at)
1991                    .await
1992                    .expect("a concurrent claim must not error — a deadlock here would")
1993            });
1994        }
1995
1996        let mut winners = 0;
1997        while let Some(res) = set.join_next().await {
1998            if res.unwrap() {
1999                winners += 1;
2000            }
2001        }
2002        assert_eq!(winners, 1, "exactly one concurrent claim may win");
2003    }
2004
2005    /// authkestra#290 (PR review, finding #4): `ensure_postgres_column`
2006    /// probes `information_schema.columns`, which spans every schema the
2007    /// role can see. Without the `table_schema = 'authkestra'` predicate a
2008    /// host application's own `public.oauth_clients` satisfies the probe
2009    /// and the ALTER that `authkestra.oauth_clients` still needs is
2010    /// skipped — silently, with the breakage surfacing later as a storage
2011    /// error on every `find_client`.
2012    ///
2013    /// Drop the qualifier from the probe and this test fails on
2014    /// `find_client`, not on `migrate()`, which is exactly what makes the
2015    /// unqualified version dangerous.
2016    #[tokio::test]
2017    async fn test_postgres_migration_is_not_confused_by_a_same_named_table_in_public() {
2018        let container = Postgres::default()
2019            .with_env_var("POSTGRES_PASSWORD", "postgres")
2020            .with_env_var("POSTGRES_USER", "postgres")
2021            .with_env_var("POSTGRES_DB", "postgres")
2022            .start()
2023            .await
2024            .unwrap();
2025        let port = container.get_host_port_ipv4(5432).await.unwrap();
2026        let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres");
2027        let pool = PgPoolOptions::new()
2028            .max_connections(5)
2029            .connect(&url)
2030            .await
2031            .unwrap();
2032
2033        // The pre-#287 authkestra schema (no new columns), plus an
2034        // unrelated host-app table of the same name in `public` that
2035        // *does* already have a `jwks` column.
2036        use sqlx::Executor;
2037        pool.execute(
2038            "CREATE SCHEMA IF NOT EXISTS authkestra;
2039             CREATE TABLE authkestra.oauth_clients (
2040                client_id VARCHAR(255) PRIMARY KEY,
2041                client_secret_hash VARCHAR(255),
2042                require_pkce BOOLEAN NOT NULL DEFAULT TRUE,
2043                redirect_uris JSONB NOT NULL,
2044                grant_types JSONB NOT NULL,
2045                scopes JSONB NOT NULL,
2046                allowed_audiences JSONB NOT NULL
2047            );
2048            CREATE TABLE authkestra.oauth_refresh_tokens (
2049                token VARCHAR(255) PRIMARY KEY,
2050                client_id VARCHAR(255) NOT NULL REFERENCES authkestra.oauth_clients(client_id) ON DELETE CASCADE,
2051                identity JSONB NOT NULL,
2052                scope TEXT NOT NULL,
2053                expires_at TIMESTAMPTZ NOT NULL,
2054                revoked_at TIMESTAMPTZ
2055            );
2056            CREATE TABLE public.oauth_clients (
2057                client_id VARCHAR(255) PRIMARY KEY,
2058                jwks JSONB
2059            );",
2060        )
2061        .await
2062        .unwrap();
2063
2064        sqlx::query(
2065            "INSERT INTO authkestra.oauth_clients
2066             (client_id, client_secret_hash, require_pkce, redirect_uris, grant_types, scopes, allowed_audiences)
2067             VALUES ($1, $2, $3, $4, $5, $6, $7)"
2068        )
2069        .bind("shadowed_client")
2070        .bind("hash")
2071        .bind(true)
2072        .bind(sqlx::types::Json(vec!["http://localhost/cb"]))
2073        .bind(sqlx::types::Json(vec!["authorization_code"]))
2074        .bind(sqlx::types::Json(vec!["openid"]))
2075        .bind(sqlx::types::Json(vec!["aud"]))
2076        .execute(&pool)
2077        .await
2078        .unwrap();
2079
2080        let mut store = SqlxOpStore::<sqlx::Postgres>::new(pool);
2081        store.migrate().await.expect("migrate must succeed");
2082
2083        let client = store
2084            .find_client("shadowed_client")
2085            .await
2086            .expect("find_client must not fail: the ALTER must have been applied to authkestra.oauth_clients, not skipped because public.oauth_clients happened to have a jwks column")
2087            .expect("the client must be found");
2088        assert_eq!(client.jwks, None);
2089        assert_eq!(client.token_endpoint_auth_method, None);
2090    }
2091}
2092
2093#[cfg(all(test, feature = "sqlite"))]
2094mod sqlite_tests {
2095    use super::*;
2096    use authkestra_op::code::{AuthorizationCode, AuthorizationCodeStore};
2097    use chrono::{Duration, Utc};
2098    use sqlx::sqlite::SqlitePoolOptions;
2099
2100    async fn setup_db() -> SqlxOpStore<sqlx::Sqlite> {
2101        let pool = SqlitePoolOptions::new()
2102            .connect("sqlite::memory:")
2103            .await
2104            .unwrap();
2105
2106        let store = SqlxOpStore::<sqlx::Sqlite>::new(pool);
2107        store.migrate().await.unwrap();
2108
2109        // Enable foreign keys in SQLite for this connection
2110        sqlx::query("PRAGMA foreign_keys = ON;")
2111            .execute(&store.pool)
2112            .await
2113            .unwrap();
2114
2115        store
2116    }
2117
2118    #[tokio::test]
2119    async fn test_sqlite_cascading_delete() {
2120        let mut store = setup_db().await;
2121
2122        // Manually insert a client
2123        sqlx::query(
2124            "INSERT INTO authkestra_oauth_clients (client_id, client_secret_hash, require_pkce, redirect_uris, grant_types, scopes, allowed_audiences) 
2125             VALUES (?, ?, ?, ?, ?, ?, ?)"
2126        )
2127        .bind("test_client")
2128        .bind("hash")
2129        .bind(true)
2130        .bind(sqlx::types::Json(vec!["http://localhost/cb"]))
2131        .bind(sqlx::types::Json(vec!["authorization_code"]))
2132        .bind(sqlx::types::Json(vec!["openid"]))
2133        .bind(sqlx::types::Json(vec!["aud"]))
2134        .execute(&store.pool)
2135        .await
2136        .unwrap();
2137
2138        let code = AuthorizationCode::new(
2139            "test_code_123".to_string(),
2140            "test_client".to_string(),
2141            "http://localhost/cb".to_string(),
2142            "openid".to_string(),
2143            authkestra_engine::auth::state::Identity {
2144                provider_id: "local".to_string(),
2145                external_id: "user_1".to_string(),
2146                email: None,
2147                username: None,
2148                attributes: std::collections::HashMap::new(),
2149            },
2150            Utc::now() + Duration::try_minutes(10).unwrap(),
2151            false,
2152        );
2153
2154        store.store_code(code.clone()).await.unwrap();
2155
2156        // Consume it to verify it exists
2157        let consumed = store.consume_code("test_code_123").await.unwrap();
2158        assert!(consumed.is_some());
2159        assert_eq!(consumed.unwrap().client_id, "test_client");
2160
2161        // Test cascade delete
2162        let mut code2 = code;
2163        code2.code = "test_code_456".to_string();
2164        store.store_code(code2.clone()).await.unwrap();
2165
2166        // Delete the client
2167        sqlx::query("DELETE FROM authkestra_oauth_clients WHERE client_id = 'test_client'")
2168            .execute(&store.pool)
2169            .await
2170            .unwrap();
2171
2172        // Ensure the code is also deleted due to CASCADE
2173        let count: (i64,) = sqlx::query_as(
2174            "SELECT COUNT(*) FROM authkestra_oauth_codes WHERE code = 'test_code_456'",
2175        )
2176        .fetch_one(&store.pool)
2177        .await
2178        .unwrap();
2179
2180        assert_eq!(count.0, 0);
2181    }
2182
2183    #[tokio::test]
2184    async fn test_sqlite_concurrency() {
2185        let mut store = setup_db().await;
2186
2187        sqlx::query(
2188            "INSERT INTO authkestra_oauth_clients (client_id, client_secret_hash, require_pkce, redirect_uris, grant_types, scopes, allowed_audiences) 
2189             VALUES (?, ?, ?, ?, ?, ?, ?)"
2190        )
2191        .bind("concurrency_client")
2192        .bind("hash")
2193        .bind(true)
2194        .bind(sqlx::types::Json(vec!["http://localhost/cb"]))
2195        .bind(sqlx::types::Json(vec!["authorization_code"]))
2196        .bind(sqlx::types::Json(vec!["openid"]))
2197        .bind(sqlx::types::Json(vec!["aud"]))
2198        .execute(&store.pool)
2199        .await
2200        .unwrap();
2201
2202        let code = AuthorizationCode::new(
2203            "concurrent_code".to_string(),
2204            "concurrency_client".to_string(),
2205            "http://localhost/cb".to_string(),
2206            "openid".to_string(),
2207            authkestra_engine::auth::state::Identity {
2208                provider_id: "local".to_string(),
2209                external_id: "user_1".to_string(),
2210                email: None,
2211                username: None,
2212                attributes: std::collections::HashMap::new(),
2213            },
2214            Utc::now() + Duration::try_minutes(10).unwrap(),
2215            false,
2216        );
2217        store.store_code(code.clone()).await.unwrap();
2218
2219        let mut handles = vec![];
2220        let store_arc = store.clone();
2221
2222        // Spawn 10 simultaneous consumers
2223        for _ in 0..10 {
2224            let mut s = store_arc.clone();
2225            handles.push(tokio::spawn(async move {
2226                s.consume_code("concurrent_code").await.unwrap()
2227            }));
2228        }
2229
2230        let mut successes = 0;
2231        let mut failures = 0;
2232        for h in handles {
2233            let res = h.await.unwrap();
2234            if res.is_some() {
2235                successes += 1;
2236            } else {
2237                failures += 1;
2238            }
2239        }
2240
2241        assert_eq!(successes, 1);
2242        assert_eq!(failures, 9);
2243    }
2244
2245    fn test_identity() -> authkestra_engine::auth::state::Identity {
2246        authkestra_engine::auth::state::Identity {
2247            provider_id: "local".to_string(),
2248            external_id: "user_1".to_string(),
2249            email: None,
2250            username: None,
2251            attributes: std::collections::HashMap::new(),
2252        }
2253    }
2254
2255    /// authkestra#287: `jkt` (RFC 9449 DPoP refresh-token continuity) and
2256    /// `token_endpoint_auth_method`/`jwks` (RFC 7523 private_key_jwt) must
2257    /// actually round-trip through a fresh install, not just exist as
2258    /// columns nothing reads or writes.
2259    #[tokio::test]
2260    async fn test_sqlite_fresh_install_persists_jkt_and_client_auth_fields() {
2261        let mut store = setup_db().await;
2262
2263        sqlx::query(
2264            "INSERT INTO authkestra_oauth_clients
2265             (client_id, client_secret_hash, require_pkce, redirect_uris, grant_types, scopes, allowed_audiences, token_endpoint_auth_method, jwks)
2266             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
2267        )
2268        .bind("auth287_client")
2269        .bind("hash")
2270        .bind(true)
2271        .bind(sqlx::types::Json(vec!["http://localhost/cb"]))
2272        .bind(sqlx::types::Json(vec!["authorization_code"]))
2273        .bind(sqlx::types::Json(vec!["openid"]))
2274        .bind(sqlx::types::Json(vec!["aud"]))
2275        .bind(sqlx::types::Json(TokenEndpointAuthMethod::PrivateKeyJwt))
2276        .bind(sqlx::types::Json(serde_json::json!({"keys": []})))
2277        .execute(&store.pool)
2278        .await
2279        .unwrap();
2280
2281        let client = store
2282            .find_client("auth287_client")
2283            .await
2284            .unwrap()
2285            .expect("client must be found");
2286        assert_eq!(
2287            client.token_endpoint_auth_method,
2288            Some(TokenEndpointAuthMethod::PrivateKeyJwt)
2289        );
2290        assert_eq!(client.jwks, Some(serde_json::json!({"keys": []})));
2291
2292        let rt = RefreshToken::new(
2293            "rt-287".to_string(),
2294            "auth287_client".to_string(),
2295            test_identity(),
2296            "openid".to_string(),
2297            Utc::now() + Duration::try_days(1).unwrap(),
2298            Some("expected-jkt-thumbprint".to_string()),
2299        );
2300        store.store_token(rt).await.unwrap();
2301
2302        let fetched = store
2303            .get_token("rt-287")
2304            .await
2305            .unwrap()
2306            .expect("token must be found");
2307        assert_eq!(fetched.jkt, Some("expected-jkt-thumbprint".to_string()));
2308
2309        let consumed = store
2310            .consume_token("rt-287")
2311            .await
2312            .unwrap()
2313            .expect("token must be consumable");
2314        assert_eq!(consumed.jkt, Some("expected-jkt-thumbprint".to_string()));
2315    }
2316
2317    /// The actual point of authkestra#287: a deployment that already ran
2318    /// the *old* `migrate()` (before `jkt`/`token_endpoint_auth_method`/
2319    /// `jwks` existed) must upgrade safely when it starts running the new
2320    /// code — no "table already exists" failure, no data loss for
2321    /// pre-existing rows, and the new columns must actually work
2322    /// afterwards. This is the scenario `CREATE TABLE IF NOT EXISTS`
2323    /// alone could never handle.
2324    #[tokio::test]
2325    async fn test_sqlite_migration_upgrades_a_pre_existing_deployment_without_the_new_columns() {
2326        // The pre-#287 schema, created directly — bypassing
2327        // `store.migrate()` entirely, exactly like an existing deployment
2328        // that ran the old code would already have.
2329        let pool = SqlitePoolOptions::new()
2330            .connect("sqlite::memory:")
2331            .await
2332            .unwrap();
2333        sqlx::query(
2334            "CREATE TABLE authkestra_oauth_clients (
2335                client_id TEXT PRIMARY KEY,
2336                client_secret_hash TEXT,
2337                require_pkce BOOLEAN NOT NULL DEFAULT 1,
2338                redirect_uris TEXT NOT NULL,
2339                grant_types TEXT NOT NULL,
2340                scopes TEXT NOT NULL,
2341                allowed_audiences TEXT NOT NULL
2342            );",
2343        )
2344        .execute(&pool)
2345        .await
2346        .unwrap();
2347        sqlx::query(
2348            "CREATE TABLE authkestra_oauth_refresh_tokens (
2349                token TEXT PRIMARY KEY,
2350                client_id TEXT NOT NULL REFERENCES authkestra_oauth_clients(client_id) ON DELETE CASCADE,
2351                identity TEXT NOT NULL,
2352                scope TEXT NOT NULL,
2353                expires_at DATETIME NOT NULL,
2354                revoked_at DATETIME
2355            );",
2356        )
2357        .execute(&pool)
2358        .await
2359        .unwrap();
2360
2361        // A client registered under the old schema, before this
2362        // deployment ever knew about these fields.
2363        sqlx::query(
2364            "INSERT INTO authkestra_oauth_clients
2365             (client_id, client_secret_hash, require_pkce, redirect_uris, grant_types, scopes, allowed_audiences)
2366             VALUES (?, ?, ?, ?, ?, ?, ?)"
2367        )
2368        .bind("pre_existing_client")
2369        .bind("hash")
2370        .bind(true)
2371        .bind(sqlx::types::Json(vec!["http://localhost/cb"]))
2372        .bind(sqlx::types::Json(vec!["authorization_code"]))
2373        .bind(sqlx::types::Json(vec!["openid"]))
2374        .bind(sqlx::types::Json(vec!["aud"]))
2375        .execute(&pool)
2376        .await
2377        .unwrap();
2378
2379        let mut store = SqlxOpStore::<sqlx::Sqlite>::new(pool);
2380
2381        store
2382            .migrate()
2383            .await
2384            .expect("migrating an existing pre-authkestra#287 database must succeed");
2385
2386        // The pre-existing client survives untouched — the new fields are
2387        // simply absent, not an error.
2388        let client = store
2389            .find_client("pre_existing_client")
2390            .await
2391            .unwrap()
2392            .expect("the pre-existing client must survive the migration");
2393        assert_eq!(client.token_endpoint_auth_method, None);
2394        assert_eq!(client.jwks, None);
2395
2396        // And the new columns are now genuinely usable.
2397        let rt = RefreshToken::new(
2398            "rt-upgrade".to_string(),
2399            "pre_existing_client".to_string(),
2400            test_identity(),
2401            "openid".to_string(),
2402            Utc::now() + Duration::try_days(1).unwrap(),
2403            Some("post-upgrade-jkt".to_string()),
2404        );
2405        store
2406            .store_token(rt)
2407            .await
2408            .expect("storing a DPoP-bound refresh token must work after the upgrade");
2409        let fetched = store
2410            .get_token("rt-upgrade")
2411            .await
2412            .unwrap()
2413            .expect("token must be found");
2414        assert_eq!(fetched.jkt, Some("post-upgrade-jkt".to_string()));
2415    }
2416
2417    /// authkestra#290 (PR review, HIGH #1): `sqlx::migrate!`'s bookkeeping
2418    /// lives in one database-global, unqualified `_sqlx_migrations` table
2419    /// that sqlx 0.8 provides no way to rename or namespace. `SqlxOpStore`
2420    /// is embedded into a *host application's own* connection pool, and a
2421    /// host that also runs `sqlx::migrate!` for its own schema against that
2422    /// same pool -- a completely ordinary setup -- collided with it the
2423    /// moment either side's migration version number matched the other's,
2424    /// with `Migrate::run` then refusing to apply *either* migration set.
2425    /// `SqlxOpStore::migrate` no longer uses `sqlx::migrate!` at all (see
2426    /// its doc comment), so it keeps no bookkeeping of its own and cannot
2427    /// collide -- verified here directly against a real `sqlx::migrate!`
2428    /// call, in both orderings, sharing one pool.
2429    #[tokio::test]
2430    async fn test_sqlite_migrate_does_not_collide_with_a_host_apps_own_sqlx_migrate() {
2431        // Order A: the host app's own sqlx::migrate! runs first.
2432        {
2433            let pool = SqlitePoolOptions::new()
2434                .connect("sqlite::memory:")
2435                .await
2436                .unwrap();
2437
2438            sqlx::migrate!("./tests/fixture_migrations/host_app")
2439                .run(&pool)
2440                .await
2441                .expect("the host app's own migrator must succeed");
2442
2443            let store = SqlxOpStore::<sqlx::Sqlite>::new(pool.clone());
2444            store
2445                .migrate()
2446                .await
2447                .expect("authkestra-op's migrate() must not be blocked by a host app's prior sqlx::migrate! run on the same pool");
2448
2449            sqlx::query("SELECT id FROM app_widgets")
2450                .fetch_optional(&pool)
2451                .await
2452                .expect("the host app's own table must still exist and be queryable");
2453            let client_count: i64 =
2454                sqlx::query_scalar("SELECT COUNT(*) FROM authkestra_oauth_clients")
2455                    .fetch_one(&pool)
2456                    .await
2457                    .expect("authkestra-op's own tables must exist and be queryable");
2458            assert_eq!(client_count, 0);
2459        }
2460
2461        // Order B: authkestra-op's migrate() runs first, the host app's
2462        // own sqlx::migrate! runs second. The reviewer's reproduction
2463        // showed the reverse order broke the *other* side, so both
2464        // orderings must be exercised.
2465        {
2466            let pool = SqlitePoolOptions::new()
2467                .connect("sqlite::memory:")
2468                .await
2469                .unwrap();
2470
2471            let store = SqlxOpStore::<sqlx::Sqlite>::new(pool.clone());
2472            store.migrate().await.unwrap();
2473
2474            sqlx::migrate!("./tests/fixture_migrations/host_app")
2475                .run(&pool)
2476                .await
2477                .expect("the host app's own migrator must not be blocked by authkestra-op's prior migrate() run on the same pool");
2478
2479            sqlx::query("SELECT id FROM app_widgets")
2480                .fetch_optional(&pool)
2481                .await
2482                .expect("the host app's own table must exist and be queryable");
2483        }
2484    }
2485
2486    /// authkestra#290 (PR review, HIGH #2): a `token_endpoint_auth_method`
2487    /// value the enum can't decode (e.g. an operator-written
2488    /// `client_secret_jwt`, which this enum has no variant for) must
2489    /// surface as a storage error, not silently become `None` -- `None`
2490    /// means "no auth method configured" to `authenticate_client`, which
2491    /// combined with a NULL `client_secret_hash` (a legitimate shape for a
2492    /// private_key_jwt-only client) authenticates the client with zero
2493    /// credentials.
2494    #[tokio::test]
2495    async fn test_sqlite_find_client_rejects_an_undecodable_token_endpoint_auth_method() {
2496        let mut store = setup_db().await;
2497
2498        sqlx::query(
2499            "INSERT INTO authkestra_oauth_clients
2500             (client_id, client_secret_hash, require_pkce, redirect_uris, grant_types, scopes, allowed_audiences, token_endpoint_auth_method)
2501             VALUES (?, NULL, ?, ?, ?, ?, ?, ?)"
2502        )
2503        .bind("malformed_client")
2504        .bind(true)
2505        .bind(sqlx::types::Json(vec!["http://localhost/cb"]))
2506        .bind(sqlx::types::Json(vec!["authorization_code"]))
2507        .bind(sqlx::types::Json(vec!["openid"]))
2508        .bind(sqlx::types::Json(vec!["aud"]))
2509        .bind(r#""client_secret_jwt""#)
2510        .execute(&store.pool)
2511        .await
2512        .unwrap();
2513
2514        let result = store.find_client("malformed_client").await;
2515        assert!(
2516            matches!(result, Err(authkestra_engine::store::StoreError::Internal(_))),
2517            "an undecodable token_endpoint_auth_method must fail closed as a storage error, not silently decode to None: got {result:?}"
2518        );
2519    }
2520
2521    /// The point of authkestra#291: before this, `SqlxOpStore` carried an
2522    /// empty `impl OpStore`, so it inherited the fail-closed
2523    /// `NoDpopReplayStore` default and refused *every* DPoP proof. A first
2524    /// claim must now succeed and an immediate replay must be refused.
2525    #[tokio::test]
2526    async fn test_sqlite_dpop_jti_is_claimed_once_and_replay_is_refused() {
2527        use authkestra_op::store::OpStore;
2528        let mut store = setup_db().await;
2529        let expires_at = Utc::now() + Duration::seconds(60);
2530
2531        assert!(
2532            store
2533                .check_and_record_dpop_jti("jti-291", expires_at)
2534                .await
2535                .unwrap(),
2536            "a fresh jti must be claimable — a false here is the fail-closed \
2537             default this override exists to replace"
2538        );
2539        assert!(
2540            !store
2541                .check_and_record_dpop_jti("jti-291", expires_at)
2542                .await
2543                .unwrap(),
2544            "replaying a still-fresh jti must be refused"
2545        );
2546    }
2547
2548    /// A `jti` past its window is reclaimable: `verify_dpop_proof` rejects
2549    /// such a proof on freshness first, so keeping the row would only grow
2550    /// the table forever.
2551    #[tokio::test]
2552    async fn test_sqlite_dpop_jti_is_reclaimable_once_expired() {
2553        use authkestra_op::store::OpStore;
2554        let mut store = setup_db().await;
2555
2556        assert!(store
2557            .check_and_record_dpop_jti("jti-expired", Utc::now() - Duration::seconds(1))
2558            .await
2559            .unwrap());
2560        assert!(
2561            store
2562                .check_and_record_dpop_jti("jti-expired", Utc::now() + Duration::seconds(60))
2563                .await
2564                .unwrap(),
2565            "an expired jti must be reclaimable"
2566        );
2567    }
2568
2569    /// The guarantee the single-statement upsert exists for. A
2570    /// `SELECT`-then-`INSERT` implementation passes both tests above and
2571    /// fails this one, because its TOCTOU window is the replay itself.
2572    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2573    async fn test_sqlite_dpop_jti_claim_is_atomic_under_concurrency() {
2574        use authkestra_op::store::OpStore;
2575        let store = setup_db().await;
2576        let expires_at = Utc::now() + Duration::seconds(60);
2577
2578        let mut set = tokio::task::JoinSet::new();
2579        for _ in 0..16 {
2580            let mut store = store.clone();
2581            set.spawn(async move {
2582                store
2583                    .check_and_record_dpop_jti("jti-race", expires_at)
2584                    .await
2585                    .unwrap()
2586            });
2587        }
2588
2589        let mut winners = 0;
2590        while let Some(res) = set.join_next().await {
2591            if res.unwrap() {
2592                winners += 1;
2593            }
2594        }
2595        assert_eq!(
2596            winners, 1,
2597            "exactly one concurrent claim of the same jti may win"
2598        );
2599    }
2600
2601    /// authkestra#290 (PR review, finding #1): `ensure_sqlite_column`'s
2602    /// probe-then-ALTER is not atomic. Two replicas calling `migrate()` at
2603    /// the same time both see the column as absent and both issue the
2604    /// `ALTER`; the loser must treat "it's already there" as the intended
2605    /// end state, not as a failed migration that aborts startup.
2606    ///
2607    /// Driven deterministically rather than by racing two tasks: the probe
2608    /// is asked about a name that genuinely doesn't exist while the DDL
2609    /// names one that does, which produces byte-for-byte the error the
2610    /// losing replica receives.
2611    #[tokio::test]
2612    async fn test_sqlite_ensure_column_tolerates_a_concurrent_duplicate_add() {
2613        let store = setup_db().await;
2614
2615        ensure_sqlite_column(
2616            &store.pool,
2617            "authkestra_oauth_clients",
2618            "not_a_real_column",
2619            "client_id TEXT",
2620        )
2621        .await
2622        .expect("a duplicate-column ALTER must be treated as already-migrated");
2623    }
2624
2625    /// The other half of the finding #1 fix, and the reason it can't be a
2626    /// blanket `.ok()`: everything that is *not* a duplicate column must
2627    /// still abort the migration.
2628    #[tokio::test]
2629    async fn test_sqlite_ensure_column_still_propagates_unrelated_alter_failures() {
2630        let store = setup_db().await;
2631
2632        let err = ensure_sqlite_column(&store.pool, "no_such_table", "c", "c TEXT")
2633            .await
2634            .expect_err("a missing table must stay fatal");
2635        assert!(
2636            !is_sqlite_duplicate_column(&err),
2637            "a missing table must not be classified as a duplicate column: {err:?}"
2638        );
2639    }
2640}
2641
2642#[cfg(all(test, feature = "mysql"))]
2643mod mysql_tests {
2644    use super::*;
2645    use authkestra_op::code::{AuthorizationCode, AuthorizationCodeStore};
2646    use chrono::{Duration, Utc};
2647    use sqlx::mysql::MySqlPoolOptions;
2648    use testcontainers::{runners::AsyncRunner, ContainerAsync, ImageExt};
2649    use testcontainers_modules::mysql::Mysql;
2650
2651    async fn setup_db() -> (SqlxOpStore<sqlx::MySql>, ContainerAsync<Mysql>) {
2652        let container = Mysql::default()
2653            .with_env_var("MYSQL_ROOT_PASSWORD", "mysql")
2654            .with_env_var("MYSQL_DATABASE", "mysql")
2655            .start()
2656            .await
2657            .unwrap();
2658        let port = container.get_host_port_ipv4(3306).await.unwrap();
2659        let url = format!("mysql://root:mysql@127.0.0.1:{port}/mysql");
2660
2661        let pool = MySqlPoolOptions::new()
2662            .max_connections(5)
2663            .connect(&url)
2664            .await
2665            .unwrap();
2666
2667        let store = SqlxOpStore::<sqlx::MySql>::new(pool);
2668        store.migrate().await.unwrap();
2669
2670        (store, container)
2671    }
2672
2673    #[tokio::test]
2674    async fn test_mysql_cascading_delete() {
2675        let (mut store, _c) = setup_db().await;
2676
2677        // Manually insert a client
2678        sqlx::query(
2679            "INSERT INTO authkestra_oauth_clients (client_id, client_secret_hash, require_pkce, redirect_uris, grant_types, scopes, allowed_audiences) 
2680             VALUES (?, ?, ?, ?, ?, ?, ?)"
2681        )
2682        .bind("test_client")
2683        .bind("hash")
2684        .bind(true)
2685        .bind(sqlx::types::Json(vec!["http://localhost/cb"]))
2686        .bind(sqlx::types::Json(vec!["authorization_code"]))
2687        .bind(sqlx::types::Json(vec!["openid"]))
2688        .bind(sqlx::types::Json(vec!["aud"]))
2689        .execute(&store.pool)
2690        .await
2691        .unwrap();
2692
2693        let code = AuthorizationCode::new(
2694            "test_code_123".to_string(),
2695            "test_client".to_string(),
2696            "http://localhost/cb".to_string(),
2697            "openid".to_string(),
2698            authkestra_engine::auth::state::Identity {
2699                provider_id: "local".to_string(),
2700                external_id: "user_1".to_string(),
2701                email: None,
2702                username: None,
2703                attributes: std::collections::HashMap::new(),
2704            },
2705            Utc::now() + Duration::try_minutes(10).unwrap(),
2706            false,
2707        );
2708
2709        store.store_code(code.clone()).await.unwrap();
2710
2711        // Consume it to verify it exists
2712        let consumed = store.consume_code("test_code_123").await.unwrap();
2713        assert!(consumed.is_some());
2714        assert_eq!(consumed.unwrap().client_id, "test_client");
2715
2716        // Test cascade delete
2717        let mut code2 = code;
2718        code2.code = "test_code_456".to_string();
2719        store.store_code(code2.clone()).await.unwrap();
2720
2721        // Delete the client
2722        sqlx::query("DELETE FROM authkestra_oauth_clients WHERE client_id = 'test_client'")
2723            .execute(&store.pool)
2724            .await
2725            .unwrap();
2726
2727        // Ensure the code is also deleted due to CASCADE
2728        let count: (i64,) = sqlx::query_as(
2729            "SELECT COUNT(*) FROM authkestra_oauth_codes WHERE code = 'test_code_456'",
2730        )
2731        .fetch_one(&store.pool)
2732        .await
2733        .unwrap();
2734
2735        assert_eq!(count.0, 0);
2736    }
2737
2738    /// MySQL is the one backend whose `consume_code` opens a transaction of
2739    /// its own (no `UPDATE ... RETURNING`, so it needs
2740    /// `SELECT ... FOR UPDATE`). Called inside a caller's transaction that
2741    /// has to degrade to a nested savepoint governed by the outer rollback —
2742    /// otherwise the consume commits independently and the caller's unit of
2743    /// work silently isn't one. authkestra#336.
2744    #[tokio::test]
2745    async fn test_mysql_consume_inside_a_caller_transaction_rolls_back_with_it() {
2746        let (mut store, _c) = setup_db().await;
2747
2748        sqlx::query(
2749            "INSERT INTO authkestra_oauth_clients (client_id, client_secret_hash, require_pkce, redirect_uris, grant_types, scopes, allowed_audiences)
2750             VALUES (?, ?, ?, ?, ?, ?, ?)"
2751        )
2752        .bind("tx_client")
2753        .bind("hash")
2754        .bind(true)
2755        .bind(sqlx::types::Json(vec!["http://localhost/cb"]))
2756        .bind(sqlx::types::Json(vec!["authorization_code"]))
2757        .bind(sqlx::types::Json(vec!["openid"]))
2758        .bind(sqlx::types::Json(vec!["aud"]))
2759        .execute(&store.pool)
2760        .await
2761        .unwrap();
2762
2763        let code = AuthorizationCode::new(
2764            "tx_code_1".to_string(),
2765            "tx_client".to_string(),
2766            "http://localhost/cb".to_string(),
2767            "openid".to_string(),
2768            authkestra_engine::auth::state::Identity {
2769                provider_id: "local".to_string(),
2770                external_id: "user_1".to_string(),
2771                email: None,
2772                username: None,
2773                attributes: std::collections::HashMap::new(),
2774            },
2775            Utc::now() + Duration::try_minutes(10).unwrap(),
2776            false,
2777        );
2778
2779        let mut tx = store.begin_tx().await.unwrap();
2780        tx.store_code(code.clone()).await.unwrap();
2781        let consumed = tx.consume_code(&code.code).await.unwrap();
2782        assert!(
2783            consumed.is_some(),
2784            "the FOR UPDATE consume must work inside a caller's transaction, as a savepoint"
2785        );
2786        tx.rollback().await.unwrap();
2787
2788        assert!(
2789            store.consume_code(&code.code).await.unwrap().is_none(),
2790            "store-then-consume rolled back as a unit must leave nothing behind"
2791        );
2792    }
2793
2794    #[tokio::test]
2795    async fn test_mysql_concurrency() {
2796        let (mut store, _c) = setup_db().await;
2797
2798        sqlx::query(
2799            "INSERT INTO authkestra_oauth_clients (client_id, client_secret_hash, require_pkce, redirect_uris, grant_types, scopes, allowed_audiences) 
2800             VALUES (?, ?, ?, ?, ?, ?, ?)"
2801        )
2802        .bind("concurrency_client")
2803        .bind("hash")
2804        .bind(true)
2805        .bind(sqlx::types::Json(vec!["http://localhost/cb"]))
2806        .bind(sqlx::types::Json(vec!["authorization_code"]))
2807        .bind(sqlx::types::Json(vec!["openid"]))
2808        .bind(sqlx::types::Json(vec!["aud"]))
2809        .execute(&store.pool)
2810        .await
2811        .unwrap();
2812
2813        let code = AuthorizationCode::new(
2814            "concurrent_code".to_string(),
2815            "concurrency_client".to_string(),
2816            "http://localhost/cb".to_string(),
2817            "openid".to_string(),
2818            authkestra_engine::auth::state::Identity {
2819                provider_id: "local".to_string(),
2820                external_id: "user_1".to_string(),
2821                email: None,
2822                username: None,
2823                attributes: std::collections::HashMap::new(),
2824            },
2825            Utc::now() + Duration::try_minutes(10).unwrap(),
2826            false,
2827        );
2828        store.store_code(code.clone()).await.unwrap();
2829
2830        let mut handles = vec![];
2831        let store_arc = store.clone();
2832
2833        // Spawn 10 simultaneous consumers
2834        for _ in 0..10 {
2835            let mut s = store_arc.clone();
2836            handles.push(tokio::spawn(async move {
2837                s.consume_code("concurrent_code").await.unwrap()
2838            }));
2839        }
2840
2841        let mut successes = 0;
2842        let mut failures = 0;
2843        for h in handles {
2844            let res = h.await.unwrap();
2845            if res.is_some() {
2846                successes += 1;
2847            } else {
2848                failures += 1;
2849            }
2850        }
2851
2852        assert_eq!(successes, 1);
2853        assert_eq!(failures, 9);
2854    }
2855
2856    fn test_identity() -> authkestra_engine::auth::state::Identity {
2857        authkestra_engine::auth::state::Identity {
2858            provider_id: "local".to_string(),
2859            external_id: "user_1".to_string(),
2860            email: None,
2861            username: None,
2862            attributes: std::collections::HashMap::new(),
2863        }
2864    }
2865
2866    /// authkestra#287: `jkt` (RFC 9449 DPoP refresh-token continuity) and
2867    /// `token_endpoint_auth_method`/`jwks` (RFC 7523 private_key_jwt) must
2868    /// actually round-trip through a fresh install, not just exist as
2869    /// columns nothing reads or writes.
2870    #[tokio::test]
2871    async fn test_mysql_fresh_install_persists_jkt_and_client_auth_fields() {
2872        let (mut store, _c) = setup_db().await;
2873
2874        sqlx::query(
2875            "INSERT INTO authkestra_oauth_clients
2876             (client_id, client_secret_hash, require_pkce, redirect_uris, grant_types, scopes, allowed_audiences, token_endpoint_auth_method, jwks)
2877             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
2878        )
2879        .bind("auth287_client")
2880        .bind("hash")
2881        .bind(true)
2882        .bind(sqlx::types::Json(vec!["http://localhost/cb"]))
2883        .bind(sqlx::types::Json(vec!["authorization_code"]))
2884        .bind(sqlx::types::Json(vec!["openid"]))
2885        .bind(sqlx::types::Json(vec!["aud"]))
2886        .bind(sqlx::types::Json(TokenEndpointAuthMethod::PrivateKeyJwt))
2887        .bind(sqlx::types::Json(serde_json::json!({"keys": []})))
2888        .execute(&store.pool)
2889        .await
2890        .unwrap();
2891
2892        let client = store
2893            .find_client("auth287_client")
2894            .await
2895            .unwrap()
2896            .expect("client must be found");
2897        assert_eq!(
2898            client.token_endpoint_auth_method,
2899            Some(TokenEndpointAuthMethod::PrivateKeyJwt)
2900        );
2901        assert_eq!(client.jwks, Some(serde_json::json!({"keys": []})));
2902
2903        let rt = RefreshToken::new(
2904            "rt-287".to_string(),
2905            "auth287_client".to_string(),
2906            test_identity(),
2907            "openid".to_string(),
2908            Utc::now() + Duration::try_days(1).unwrap(),
2909            Some("expected-jkt-thumbprint".to_string()),
2910        );
2911        store.store_token(rt).await.unwrap();
2912
2913        let fetched = store
2914            .get_token("rt-287")
2915            .await
2916            .unwrap()
2917            .expect("token must be found");
2918        assert_eq!(fetched.jkt, Some("expected-jkt-thumbprint".to_string()));
2919
2920        let consumed = store
2921            .consume_token("rt-287")
2922            .await
2923            .unwrap()
2924            .expect("token must be consumable");
2925        assert_eq!(consumed.jkt, Some("expected-jkt-thumbprint".to_string()));
2926    }
2927
2928    /// The actual point of authkestra#287: a deployment that already ran
2929    /// the *old* `migrate()` (before `jkt`/`token_endpoint_auth_method`/
2930    /// `jwks` existed) must upgrade safely when it starts running the new
2931    /// code — no "table already exists" failure, no data loss for
2932    /// pre-existing rows, and the new columns must actually work
2933    /// afterwards. This is the scenario `CREATE TABLE IF NOT EXISTS`
2934    /// alone could never handle.
2935    #[tokio::test]
2936    async fn test_mysql_migration_upgrades_a_pre_existing_deployment_without_the_new_columns() {
2937        let container = Mysql::default()
2938            .with_env_var("MYSQL_ROOT_PASSWORD", "mysql")
2939            .with_env_var("MYSQL_DATABASE", "mysql")
2940            .start()
2941            .await
2942            .unwrap();
2943        let port = container.get_host_port_ipv4(3306).await.unwrap();
2944        let url = format!("mysql://root:mysql@127.0.0.1:{port}/mysql");
2945        let pool = MySqlPoolOptions::new()
2946            .max_connections(5)
2947            .connect(&url)
2948            .await
2949            .unwrap();
2950
2951        // The pre-#287 schema, created directly — bypassing
2952        // `store.migrate()` entirely, exactly like an existing deployment
2953        // that ran the old code would already have. Each statement is its
2954        // own `sqlx::query` call: MySQL's protocol (like Postgres's)
2955        // doesn't accept multiple `;`-separated statements through a
2956        // single prepared-statement execution.
2957        sqlx::query(
2958            "CREATE TABLE authkestra_oauth_clients (
2959                client_id VARCHAR(255) PRIMARY KEY,
2960                client_secret_hash VARCHAR(255),
2961                require_pkce BOOLEAN NOT NULL DEFAULT TRUE,
2962                redirect_uris JSON NOT NULL,
2963                grant_types JSON NOT NULL,
2964                scopes JSON NOT NULL,
2965                allowed_audiences JSON NOT NULL
2966            )",
2967        )
2968        .execute(&pool)
2969        .await
2970        .unwrap();
2971        sqlx::query(
2972            "CREATE TABLE authkestra_oauth_refresh_tokens (
2973                token VARCHAR(255) PRIMARY KEY,
2974                client_id VARCHAR(255) NOT NULL,
2975                identity JSON NOT NULL,
2976                scope TEXT NOT NULL,
2977                expires_at DATETIME NOT NULL,
2978                revoked_at DATETIME,
2979                FOREIGN KEY (client_id) REFERENCES authkestra_oauth_clients(client_id) ON DELETE CASCADE
2980            )",
2981        )
2982        .execute(&pool)
2983        .await
2984        .unwrap();
2985
2986        // A client registered under the old schema, before this
2987        // deployment ever knew about these fields.
2988        sqlx::query(
2989            "INSERT INTO authkestra_oauth_clients
2990             (client_id, client_secret_hash, require_pkce, redirect_uris, grant_types, scopes, allowed_audiences)
2991             VALUES (?, ?, ?, ?, ?, ?, ?)"
2992        )
2993        .bind("pre_existing_client")
2994        .bind("hash")
2995        .bind(true)
2996        .bind(sqlx::types::Json(vec!["http://localhost/cb"]))
2997        .bind(sqlx::types::Json(vec!["authorization_code"]))
2998        .bind(sqlx::types::Json(vec!["openid"]))
2999        .bind(sqlx::types::Json(vec!["aud"]))
3000        .execute(&pool)
3001        .await
3002        .unwrap();
3003
3004        let mut store = SqlxOpStore::<sqlx::MySql>::new(pool);
3005
3006        store
3007            .migrate()
3008            .await
3009            .expect("migrating an existing pre-authkestra#287 database must succeed");
3010
3011        let client = store
3012            .find_client("pre_existing_client")
3013            .await
3014            .unwrap()
3015            .expect("the pre-existing client must survive the migration");
3016        assert_eq!(client.token_endpoint_auth_method, None);
3017        assert_eq!(client.jwks, None);
3018
3019        let rt = RefreshToken::new(
3020            "rt-upgrade".to_string(),
3021            "pre_existing_client".to_string(),
3022            test_identity(),
3023            "openid".to_string(),
3024            Utc::now() + Duration::try_days(1).unwrap(),
3025            Some("post-upgrade-jkt".to_string()),
3026        );
3027        store
3028            .store_token(rt)
3029            .await
3030            .expect("storing a DPoP-bound refresh token must work after the upgrade");
3031        let fetched = store
3032            .get_token("rt-upgrade")
3033            .await
3034            .unwrap()
3035            .expect("token must be found");
3036        assert_eq!(fetched.jkt, Some("post-upgrade-jkt".to_string()));
3037    }
3038
3039    /// authkestra#291: `SqlxOpStore` used to inherit the fail-closed
3040    /// `NoDpopReplayStore` default, refusing every DPoP proof.
3041    #[tokio::test]
3042    async fn test_mysql_dpop_jti_is_claimed_once_and_replay_is_refused() {
3043        use authkestra_op::store::OpStore;
3044        let (mut store, _c) = setup_db().await;
3045        let expires_at = Utc::now() + Duration::seconds(60);
3046
3047        assert!(store
3048            .check_and_record_dpop_jti("jti-291", expires_at)
3049            .await
3050            .unwrap());
3051        assert!(
3052            !store
3053                .check_and_record_dpop_jti("jti-291", expires_at)
3054                .await
3055                .unwrap(),
3056            "replaying a still-fresh jti must be refused"
3057        );
3058    }
3059
3060    #[tokio::test]
3061    async fn test_mysql_dpop_jti_is_reclaimable_once_expired() {
3062        use authkestra_op::store::OpStore;
3063        let (mut store, _c) = setup_db().await;
3064
3065        assert!(store
3066            .check_and_record_dpop_jti("jti-expired", Utc::now() - Duration::seconds(5))
3067            .await
3068            .unwrap());
3069        assert!(
3070            store
3071                .check_and_record_dpop_jti("jti-expired", Utc::now() + Duration::seconds(60))
3072                .await
3073                .unwrap(),
3074            "an expired jti must be reclaimable"
3075        );
3076    }
3077
3078    /// Guards both the TOCTOU window and, on MySQL, the gap-lock deadlock
3079    /// that a `SELECT ... FOR UPDATE` transaction would hit on a
3080    /// not-yet-existing row under the default REPEATABLE READ.
3081    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
3082    async fn test_mysql_dpop_jti_claim_is_atomic_under_concurrency() {
3083        use authkestra_op::store::OpStore;
3084        let (store, _c) = setup_db().await;
3085        let expires_at = Utc::now() + Duration::seconds(60);
3086
3087        let mut set = tokio::task::JoinSet::new();
3088        for _ in 0..16 {
3089            let mut store = store.clone();
3090            set.spawn(async move {
3091                store
3092                    .check_and_record_dpop_jti("jti-race", expires_at)
3093                    .await
3094                    .expect("a concurrent claim must not error — a deadlock here would")
3095            });
3096        }
3097
3098        let mut winners = 0;
3099        while let Some(res) = set.join_next().await {
3100            if res.unwrap() {
3101                winners += 1;
3102            }
3103        }
3104        assert_eq!(winners, 1, "exactly one concurrent claim may win");
3105    }
3106
3107    /// authkestra#290 (PR review, finding #1) — the MySQL half. See
3108    /// `sqlite_tests`'s identically named test for the reasoning and for
3109    /// why the race is driven deterministically instead of with two tasks.
3110    /// MySQL reports this as `ER_DUP_FIELDNAME` (1060, SQLSTATE 42S21).
3111    #[tokio::test]
3112    async fn test_mysql_ensure_column_tolerates_a_concurrent_duplicate_add() {
3113        let (store, _c) = setup_db().await;
3114
3115        ensure_mysql_column(
3116            &store.pool,
3117            "authkestra_oauth_clients",
3118            "not_a_real_column",
3119            "client_id VARCHAR(255)",
3120        )
3121        .await
3122        .expect("a duplicate-column ALTER must be treated as already-migrated");
3123    }
3124
3125    /// The other half of the finding #1 fix: a missing table is MySQL 1146,
3126    /// not 1060, and must still abort the migration.
3127    #[tokio::test]
3128    async fn test_mysql_ensure_column_still_propagates_unrelated_alter_failures() {
3129        let (store, _c) = setup_db().await;
3130
3131        let err = ensure_mysql_column(&store.pool, "no_such_table", "c", "c VARCHAR(255)")
3132            .await
3133            .expect_err("a missing table must stay fatal");
3134        assert!(
3135            !is_mysql_duplicate_column(&err),
3136            "a missing table must not be classified as a duplicate column: {err:?}"
3137        );
3138    }
3139}