1use 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#[derive(Debug)]
24#[non_exhaustive]
25pub struct SqlxOpStore<DB: sqlx::Database> {
26 pool: sqlx::Pool<DB>,
27}
28
29impl<DB: sqlx::Database> SqlxOpStore<DB> {
35 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#[derive(Debug)]
92#[non_exhaustive]
93pub struct SqlxOpStoreTx<DB: sqlx::Database> {
94 tx: sqlx::Transaction<'static, DB>,
95}
96
97impl<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 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 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#[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#[cfg(feature = "sqlite")]
180fn is_sqlite_duplicate_column(e: &sqlx::Error) -> bool {
181 e.as_database_error()
184 .is_some_and(|db| db.message().starts_with("duplicate column name:"))
185}
186
187#[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#[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 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#[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 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 #[cfg(feature = $feature)]
298 pub(crate) mod $queries {
299 use super::*;
300 #[allow(unused_imports)]
301 use sqlx::Connection as _;
302
303 pub(crate) type Conn = <$backend as sqlx::Database>::Connection;
305
306 #[allow(deprecated)] 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 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 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 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 pub fn new(pool: sqlx::Pool<$backend>) -> Self {
704 Self { pool }
705 }
706
707 $migrate_impl
708
709 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
913impl_opstore_sql! {
915 sqlx::Postgres,
916 "postgres",
917 pg_queries,
918 |i| format!("${}", i),
919 "authkestra.",
920 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 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 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 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 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 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 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
1131impl_opstore_sql! {
1133 sqlx::Sqlite,
1134 "sqlite",
1135 sqlite_queries,
1136 |_| "?".to_string(),
1137 "authkestra_",
1138 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 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 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 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 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 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 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
1331impl_opstore_sql! {
1333 sqlx::MySql,
1334 "mysql",
1335 mysql_queries,
1336 |_| "?".to_string(),
1337 "authkestra_",
1338 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 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 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 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 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 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 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 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 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 let mut code2 = code;
1675 code2.code = "test_code_456".to_string();
1676 store.store_code(code2.clone()).await.unwrap();
1677
1678 sqlx::query("DELETE FROM authkestra.oauth_clients WHERE client_id = 'test_client'")
1680 .execute(&store.pool)
1681 .await
1682 .unwrap();
1683
1684 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 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 #[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 #[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 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 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 #[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 #[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 #[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 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 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 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 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 let mut code2 = code;
2163 code2.code = "test_code_456".to_string();
2164 store.store_code(code2.clone()).await.unwrap();
2165
2166 sqlx::query("DELETE FROM authkestra_oauth_clients WHERE client_id = 'test_client'")
2168 .execute(&store.pool)
2169 .await
2170 .unwrap();
2171
2172 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 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 #[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 #[tokio::test]
2325 async fn test_sqlite_migration_upgrades_a_pre_existing_deployment_without_the_new_columns() {
2326 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 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 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 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 #[tokio::test]
2430 async fn test_sqlite_migrate_does_not_collide_with_a_host_apps_own_sqlx_migrate() {
2431 {
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 {
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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 let mut code2 = code;
2718 code2.code = "test_code_456".to_string();
2719 store.store_code(code2.clone()).await.unwrap();
2720
2721 sqlx::query("DELETE FROM authkestra_oauth_clients WHERE client_id = 'test_client'")
2723 .execute(&store.pool)
2724 .await
2725 .unwrap();
2726
2727 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 #[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 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 #[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 #[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 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 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 #[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 #[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 #[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 #[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}