diff --git a/.sqlx/query-8aa69fce514126ac8da58ee4f4a2e12c722662fed94c067c2a5ceb7355b3d697.json b/.sqlx/query-8aa69fce514126ac8da58ee4f4a2e12c722662fed94c067c2a5ceb7355b3d697.json deleted file mode 100644 index 73f89a7313..0000000000 --- a/.sqlx/query-8aa69fce514126ac8da58ee4f4a2e12c722662fed94c067c2a5ceb7355b3d697.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "MySQL", - "query": "\nINSERT INTO mcp_sessions\nSET\n token = ?,\n private_session_token = ?,\n user_token = ?,\n maybe_mcp_client_name = ?,\n maybe_mcp_client_version = ?,\n maybe_mcp_client_vendor = ?,\n ip_address_creation = ?,\n ip_address_update = ?,\n created_at = NOW(),\n expires_at = NOW() + INTERVAL ? DAY\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 9 - }, - "nullable": [] - }, - "hash": "8aa69fce514126ac8da58ee4f4a2e12c722662fed94c067c2a5ceb7355b3d697" -} diff --git a/.sqlx/query-8bf815c6e588739735b08562ff48d69ca09d088e9ef3224055a40be0f5ac388f.json b/.sqlx/query-8bf815c6e588739735b08562ff48d69ca09d088e9ef3224055a40be0f5ac388f.json deleted file mode 100644 index 620ffdda6b..0000000000 --- a/.sqlx/query-8bf815c6e588739735b08562ff48d69ca09d088e9ef3224055a40be0f5ac388f.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "MySQL", - "query": "\nUPDATE mcp_sessions\nSET\n expires_at = NOW() + INTERVAL ? DAY,\n ip_address_update = ?\nWHERE private_session_token = ?\n AND maybe_deleted_at IS NULL\n AND expires_at > NOW()\nLIMIT 1\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 3 - }, - "nullable": [] - }, - "hash": "8bf815c6e588739735b08562ff48d69ca09d088e9ef3224055a40be0f5ac388f" -} diff --git a/.sqlx/query-ed8f7ef94099d2dddf50f819316485351e149db72ce8a4f5cff6b57f4b84244d.json b/.sqlx/query-ed8f7ef94099d2dddf50f819316485351e149db72ce8a4f5cff6b57f4b84244d.json new file mode 100644 index 0000000000..e57a3d52b2 --- /dev/null +++ b/.sqlx/query-ed8f7ef94099d2dddf50f819316485351e149db72ce8a4f5cff6b57f4b84244d.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "\nUPDATE mcp_sessions\nSET\n expires_at = NOW() + INTERVAL ? DAY,\n private_refresh_token = ?,\n ip_address_update = ?\nWHERE private_session_token = ?\n AND private_refresh_token = ?\n AND maybe_deleted_at IS NULL\n AND expires_at > NOW()\nLIMIT 1\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "ed8f7ef94099d2dddf50f819316485351e149db72ce8a4f5cff6b57f4b84244d" +} diff --git a/.sqlx/query-f67a73ea3d5751cc76d2d6f954bd939179b3e9005a359e04c3a4dd78f8fe562e.json b/.sqlx/query-f67a73ea3d5751cc76d2d6f954bd939179b3e9005a359e04c3a4dd78f8fe562e.json new file mode 100644 index 0000000000..6e5eb38e63 --- /dev/null +++ b/.sqlx/query-f67a73ea3d5751cc76d2d6f954bd939179b3e9005a359e04c3a4dd78f8fe562e.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "\nINSERT INTO mcp_sessions\nSET\n token = ?,\n private_session_token = ?,\n private_refresh_token = ?,\n user_token = ?,\n maybe_mcp_client_name = ?,\n maybe_mcp_client_version = ?,\n maybe_mcp_client_vendor = ?,\n ip_address_creation = ?,\n ip_address_update = ?,\n created_at = NOW(),\n expires_at = NOW() + INTERVAL ? DAY\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 10 + }, + "nullable": [] + }, + "hash": "f67a73ea3d5751cc76d2d6f954bd939179b3e9005a359e04c3a4dd78f8fe562e" +} diff --git a/_database/sql/migrations/2026-08-27-084409-0000_create_mcp_sessions/up.sql b/_database/sql/migrations/2026-08-27-084409-0000_create_mcp_sessions/up.sql index bef3159475..e5a42bc1ee 100644 --- a/_database/sql/migrations/2026-08-27-084409-0000_create_mcp_sessions/up.sql +++ b/_database/sql/migrations/2026-08-27-084409-0000_create_mcp_sessions/up.sql @@ -14,6 +14,10 @@ CREATE TABLE mcp_sessions ( -- Private entropy presented by clients; used for lookup of live sessions. private_session_token VARCHAR(64) NOT NULL, + -- Private refresh credential. Presenting this on the refresh endpoint extends the + -- session's expiry and ROTATES this value — the previous refresh token becomes invalid. + private_refresh_token VARCHAR(64) NOT NULL, + -- The user that the session belongs to. user_token VARCHAR(32) NOT NULL, @@ -53,6 +57,7 @@ CREATE TABLE mcp_sessions ( PRIMARY KEY (id), UNIQUE KEY (token), UNIQUE KEY (private_session_token), + UNIQUE KEY (private_refresh_token), KEY fk_user_token (user_token), KEY index_ip_address_creation (ip_address_creation), KEY index_maybe_deleted_at (maybe_deleted_at) diff --git a/_database/sql/migrations_squashed/mcp_sessions.sql b/_database/sql/migrations_squashed/mcp_sessions.sql index bef3159475..e5a42bc1ee 100644 --- a/_database/sql/migrations_squashed/mcp_sessions.sql +++ b/_database/sql/migrations_squashed/mcp_sessions.sql @@ -14,6 +14,10 @@ CREATE TABLE mcp_sessions ( -- Private entropy presented by clients; used for lookup of live sessions. private_session_token VARCHAR(64) NOT NULL, + -- Private refresh credential. Presenting this on the refresh endpoint extends the + -- session's expiry and ROTATES this value — the previous refresh token becomes invalid. + private_refresh_token VARCHAR(64) NOT NULL, + -- The user that the session belongs to. user_token VARCHAR(32) NOT NULL, @@ -53,6 +57,7 @@ CREATE TABLE mcp_sessions ( PRIMARY KEY (id), UNIQUE KEY (token), UNIQUE KEY (private_session_token), + UNIQUE KEY (private_refresh_token), KEY fk_user_token (user_token), KEY index_ip_address_creation (ip_address_creation), KEY index_maybe_deleted_at (maybe_deleted_at) diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/create_mcp_session.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/create_mcp_session.rs index c99025ddac..ae5b236f9c 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/create_mcp_session.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/create_mcp_session.rs @@ -2,6 +2,7 @@ use serde_derive::{Deserialize, Serialize}; use utoipa::ToSchema; use tokens::tokens::mcp_session_private::McpSessionPrivateToken; +use tokens::tokens::mcp_session_refresh::McpSessionRefreshToken; // ── POST /v1/mcp/session/create ── @@ -25,4 +26,9 @@ pub struct CreateMcpSessionSuccessResponse { /// ONLY time it is ever returned — the caller must store it now, as it cannot be retrieved /// again. pub private_session_token: McpSessionPrivateToken, + + /// The secret credential the MCP client presents on `/v1/mcp/session/refresh` to extend the + /// session. Each successful refresh returns a NEW refresh token and invalidates this one — + /// store whichever value is newest. + pub private_refresh_token: McpSessionRefreshToken, } diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/refresh_mcp_session.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/refresh_mcp_session.rs index d535f3f071..56d2582935 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/refresh_mcp_session.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/refresh_mcp_session.rs @@ -1,9 +1,22 @@ -use serde_derive::Serialize; +use serde_derive::{Deserialize, Serialize}; use utoipa::ToSchema; +use tokens::tokens::mcp_session_refresh::McpSessionRefreshToken; + // ── POST /v1/mcp/session/refresh ── +#[derive(Deserialize, ToSchema)] +pub struct RefreshMcpSessionRequest { + /// The session's CURRENT refresh token (from creation, or from the most recent refresh). + /// A stale or wrong value is an error — refresh tokens rotate and are never replayable. + pub private_refresh_token: McpSessionRefreshToken, +} + #[derive(Serialize, ToSchema)] pub struct RefreshMcpSessionSuccessResponse { pub success: bool, + + /// The NEW refresh token to present on the next refresh. The one just used is now invalid — + /// the caller must store this replacement. + pub private_refresh_token: McpSessionRefreshToken, } diff --git a/crates/schema/database/mysql_queries/src/queries/mcp_sessions/insert_mcp_session.rs b/crates/schema/database/mysql_queries/src/queries/mcp_sessions/insert_mcp_session.rs index c007266a4e..d47e8829c5 100644 --- a/crates/schema/database/mysql_queries/src/queries/mcp_sessions/insert_mcp_session.rs +++ b/crates/schema/database/mysql_queries/src/queries/mcp_sessions/insert_mcp_session.rs @@ -3,6 +3,7 @@ use std::marker::PhantomData; use sqlx::{Executor, MySql}; use tokens::tokens::mcp_session_private::McpSessionPrivateToken; +use tokens::tokens::mcp_session_refresh::McpSessionRefreshToken; use tokens::tokens::mcp_sessions::McpSessionToken; use tokens::tokens::users::UserToken; @@ -22,17 +23,19 @@ where pub phantom: PhantomData<&'c E>, } -/// Both tokens minted by [`insert_mcp_session`]. The private token is handed to the caller -/// exactly once, here — it is never selected back out of the database. +/// The tokens minted by [`insert_mcp_session`]. The private session and refresh tokens are +/// handed to the caller exactly once, here — they are never selected back out of the database. pub struct InsertedMcpSession { pub token: McpSessionToken, pub private_session_token: McpSessionPrivateToken, + pub private_refresh_token: McpSessionRefreshToken, } -/// Insert a new MCP session. Both the management `token` and the secret `private_session_token` -/// are minted here (in the data-access layer, not the handler) and returned to the caller. The -/// provided IP address is stamped on both the creation and update IP columns, and the session -/// expires [`MCP_SESSION_LIFETIME_DAYS`] days out unless refreshed. +/// Insert a new MCP session. The management `token`, the secret `private_session_token`, and +/// the initial `private_refresh_token` are all minted here (in the data-access layer, not the +/// handler) and returned to the caller. The provided IP address is stamped on both the creation +/// and update IP columns, and the session expires [`MCP_SESSION_LIFETIME_DAYS`] days out from +/// MySQL `NOW()` unless refreshed. pub async fn insert_mcp_session<'e, 'c: 'e, E>( args: InsertMcpSessionArgs<'e, 'c, E>, ) -> Result @@ -41,6 +44,7 @@ where { let token = McpSessionToken::generate(); let private_session_token = McpSessionPrivateToken::generate(); + let private_refresh_token = McpSessionRefreshToken::generate(); sqlx::query!( r#" @@ -48,6 +52,7 @@ INSERT INTO mcp_sessions SET token = ?, private_session_token = ?, + private_refresh_token = ?, user_token = ?, maybe_mcp_client_name = ?, maybe_mcp_client_version = ?, @@ -59,6 +64,7 @@ SET "#, token.as_str(), private_session_token.as_str(), + private_refresh_token.as_str(), args.user_token.as_str(), args.maybe_mcp_client_name, args.maybe_mcp_client_version, @@ -73,5 +79,6 @@ SET Ok(InsertedMcpSession { token, private_session_token, + private_refresh_token, }) } diff --git a/crates/schema/database/mysql_queries/src/queries/mcp_sessions/refresh_mcp_session.rs b/crates/schema/database/mysql_queries/src/queries/mcp_sessions/refresh_mcp_session.rs index 50a26434dd..b4ae41c65f 100644 --- a/crates/schema/database/mysql_queries/src/queries/mcp_sessions/refresh_mcp_session.rs +++ b/crates/schema/database/mysql_queries/src/queries/mcp_sessions/refresh_mcp_session.rs @@ -3,6 +3,7 @@ use std::marker::PhantomData; use sqlx::{Executor, MySql}; use tokens::tokens::mcp_session_private::McpSessionPrivateToken; +use tokens::tokens::mcp_session_refresh::McpSessionRefreshToken; use crate::queries::mcp_sessions::insert_mcp_session::MCP_SESSION_LIFETIME_DAYS; @@ -12,6 +13,9 @@ where { pub private_session_token: &'e McpSessionPrivateToken, + /// The session's CURRENT refresh credential. A stale or wrong value refreshes nothing. + pub private_refresh_token: &'e McpSessionRefreshToken, + /// Stamped on `ip_address_update`. pub ip_address: &'e str, @@ -19,32 +23,48 @@ where pub phantom: PhantomData<&'c E>, } -/// Push a live session's expiry [`MCP_SESSION_LIFETIME_DAYS`] days out from now (`updated_at` -/// bumps automatically). Only live sessions refresh: a terminated or already-expired session -/// matches no row. Returns the number of rows affected (0 = the session is not refreshable). +/// Push a live session's expiry [`MCP_SESSION_LIFETIME_DAYS`] days out from MySQL `NOW()` and +/// ROTATE its refresh credential (`updated_at` bumps automatically). Both the session +/// credential and the current refresh token must match a live, unexpired session — otherwise +/// nothing refreshes and `Ok(None)` is returned. +/// +/// The rotation is deliberately NOT idempotent: each success mints (here, in the data-access +/// layer) and returns a new refresh token, and the previous one is gone. Callers retrying a +/// refresh whose response they lost will get an error, not a replay — we don't store the +/// history of refresh tokens. pub async fn refresh_mcp_session<'e, 'c: 'e, E>( args: RefreshMcpSessionArgs<'e, 'c, E>, -) -> Result +) -> Result, sqlx::Error> where E: 'e + Executor<'c, Database = MySql>, { + let new_refresh_token = McpSessionRefreshToken::generate(); + let result = sqlx::query!( r#" UPDATE mcp_sessions SET expires_at = NOW() + INTERVAL ? DAY, + private_refresh_token = ?, ip_address_update = ? WHERE private_session_token = ? + AND private_refresh_token = ? AND maybe_deleted_at IS NULL AND expires_at > NOW() LIMIT 1 "#, MCP_SESSION_LIFETIME_DAYS, + new_refresh_token.as_str(), args.ip_address, args.private_session_token.as_str(), + args.private_refresh_token.as_str(), ) .execute(args.mysql_executor) .await?; - Ok(result.rows_affected()) + if result.rows_affected() == 0 { + return Ok(None); + } + + Ok(Some(new_refresh_token)) } diff --git a/crates/schema/public/tokens/src/prefixes.rs b/crates/schema/public/tokens/src/prefixes.rs index b1292bcae5..e292922499 100644 --- a/crates/schema/public/tokens/src/prefixes.rs +++ b/crates/schema/public/tokens/src/prefixes.rs @@ -39,6 +39,7 @@ pub(crate) enum TokenPrefix { InferenceJob, McpSession, McpSessionPrivate, // The private credential for an MCP session; carries more entropy. + McpSessionRefresh, // The rotating refresh credential for an MCP session; carries more entropy. MediaFile, MediaUpload, ModelWeight, @@ -134,6 +135,7 @@ impl PrefixGenerator for TokenPrefix { Self::InferenceJob => "jinf_", Self::McpSession => "mcp_", Self::McpSessionPrivate => "mcp_session_", + Self::McpSessionRefresh => "mcp_refresh_", Self::MediaFile => "m_", Self::MediaUpload => "mu_", Self::ModelWeight => "weight_", @@ -366,6 +368,7 @@ mod tests { || prefix == "tts_task_" || prefix == "app_session_" || prefix == "mcp_session_" + || prefix == "mcp_refresh_" { // TODO/FIXME: I'm too tired at 5AM to replacen from the left. Make this test valid. // These tokens are from the AIChatBot sidecar, so asserting their validity is less important. diff --git a/crates/schema/public/tokens/src/tokens/mcp_session_refresh.rs b/crates/schema/public/tokens/src/tokens/mcp_session_refresh.rs new file mode 100644 index 0000000000..9cffcdbf37 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/mcp_session_refresh.rs @@ -0,0 +1,24 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The rotating refresh credential for an `mcp_sessions` row (the `private_refresh_token` +/// column). +/// +/// An MCP client presents this on the session-refresh endpoint to extend its session; each +/// successful refresh ROTATES the value, invalidating the previous one. Like the session +/// credential, it is a secret with extra entropy — never list or display it after it is handed +/// out. The session's other credentials are `McpSessionToken` (management handle) and +/// `McpSessionPrivateToken` (the session secret). +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct McpSessionRefreshToken(pub String); + +impl_string_token!(McpSessionRefreshToken); +impl_mysql_token_from_row!(McpSessionRefreshToken); +impl_crockford_generator!(McpSessionRefreshToken, 64usize, TokenPrefix::McpSessionRefresh, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/mod.rs b/crates/schema/public/tokens/src/tokens/mod.rs index d465c0dd9e..30992bc518 100644 --- a/crates/schema/public/tokens/src/tokens/mod.rs +++ b/crates/schema/public/tokens/src/tokens/mod.rs @@ -16,6 +16,7 @@ pub mod generic_download_jobs; pub mod generic_inference_jobs; pub mod google_sign_in_accounts; pub mod mcp_session_private; +pub mod mcp_session_refresh; pub mod mcp_sessions; pub mod media_files; pub mod media_uploads; diff --git a/crates/service/web/storyteller_web/src/docs/api_doc.rs b/crates/service/web/storyteller_web/src/docs/api_doc.rs index 45714c19a7..5fbfaa3397 100644 --- a/crates/service/web/storyteller_web/src/docs/api_doc.rs +++ b/crates/service/web/storyteller_web/src/docs/api_doc.rs @@ -1509,6 +1509,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux McpSessionPathInfo, CreateMcpSessionRequest, CreateMcpSessionSuccessResponse, + RefreshMcpSessionRequest, RefreshMcpSessionSuccessResponse, RevokeMcpSessionSuccessResponse, DeleteMcpSessionSuccessResponse, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/create_mcp_session_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/create_mcp_session_handler.rs index f3e65c4cd3..556335e74f 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/create_mcp_session_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/create_mcp_session_handler.rs @@ -72,8 +72,10 @@ pub async fn create_mcp_session_handler( Ok(Json(CreateMcpSessionSuccessResponse { success: true, - // The only time the secret credential is ever exposed. + // The only time the session credential is ever exposed. The refresh token is exposed again + // only as the rotated replacement in refresh responses. private_session_token: inserted.private_session_token, + private_refresh_token: inserted.private_refresh_token, })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/refresh_mcp_session_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/refresh_mcp_session_handler.rs index 3aecf1ef7f..71b55cf5b2 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/refresh_mcp_session_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/refresh_mcp_session_handler.rs @@ -5,7 +5,9 @@ use actix_web::web::Json; use actix_web::{web, HttpRequest}; use log::warn; -use artcraft_api_defs::mcp_sessions::refresh_mcp_session::RefreshMcpSessionSuccessResponse; +use artcraft_api_defs::mcp_sessions::refresh_mcp_session::{ + RefreshMcpSessionRequest, RefreshMcpSessionSuccessResponse, +}; use http_server_common::request::get_request_ip::get_request_ip; use mysql_queries::queries::mcp_sessions::refresh_mcp_session::{ refresh_mcp_session, RefreshMcpSessionArgs, @@ -15,13 +17,18 @@ use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::http_server::web_utils::get_authorization_header_mcp_private_session_token::get_authorization_header_mcp_private_session_token; use crate::state::server_state::ServerState; -/// Refresh the calling MCP session (authenticated ONLY by its `private_session_token` in the -/// `Authorization` header), pushing its expiry two weeks out from now. A terminated or -/// already-expired session cannot refresh — that's a 401. +/// Refresh the calling MCP session, pushing its expiry two weeks out from now. Authenticated +/// by the session's `private_session_token` in the `Authorization` header PLUS its current +/// refresh token in the body — a session cannot bump itself without the refresh token. +/// +/// Each success ROTATES the refresh token: the response carries the new one and the one just +/// used becomes invalid. This is deliberately NOT idempotent — a retried refresh (or a stale, +/// wrong, terminated, or expired credential) is a 401, never a replay. #[utoipa::path( post, tag = "MCP Sessions", path = "/v1/mcp/session/refresh", + request_body = RefreshMcpSessionRequest, responses( (status = 200, body = RefreshMcpSessionSuccessResponse), (status = 401, body = CommonWebError), @@ -30,6 +37,7 @@ use crate::state::server_state::ServerState; )] pub async fn refresh_mcp_session_handler( http_request: HttpRequest, + request: Json, server_state: web::Data>, ) -> Result, CommonWebError> { let private_session_token = @@ -46,8 +54,9 @@ pub async fn refresh_mcp_session_handler( CommonWebError::from_error(err) })?; - let refreshed_rows = refresh_mcp_session(RefreshMcpSessionArgs { + let maybe_new_refresh_token = refresh_mcp_session(RefreshMcpSessionArgs { private_session_token: &private_session_token, + private_refresh_token: &request.private_refresh_token, ip_address: &ip_address, mysql_executor: &mut *conn, phantom: PhantomData, @@ -56,11 +65,169 @@ pub async fn refresh_mcp_session_handler( CommonWebError::from_error(err) })?; - // An unknown, terminated, or expired session is a 401, not a leak of which case occurred. - if refreshed_rows == 0 { - warn!("No refreshable MCP session for presented credential"); - return Err(CommonWebError::NotAuthorized); + // An unknown, terminated, or expired session — or a stale/wrong refresh token — is a 401, + // not a leak of which case occurred. + let new_refresh_token = match maybe_new_refresh_token { + Some(new_refresh_token) => new_refresh_token, + None => { + warn!("No refreshable MCP session for presented credentials"); + return Err(CommonWebError::NotAuthorized); + } + }; + + Ok(Json(RefreshMcpSessionSuccessResponse { + success: true, + private_refresh_token: new_refresh_token, + })) +} + +#[cfg(test)] +mod tests { + //! DATABASE TESTS: these connect to the guarded MySQL test database (see the `mysql_testing` + //! crate — never production, never the local dev database) and drive the real handler with + //! dummy Actix HTTP requests. They RUN BY DEFAULT under `cargo test`; on machines without a + //! local MySQL, skip them with: + //! + //! ```bash + //! SQLX_OFFLINE=true cargo test -p storyteller-web --features skip_database_tests + //! ``` + + use actix_web::test::TestRequest; + use actix_web::web::Data; + + use mysql_queries::queries::mcp_sessions::insert_mcp_session::InsertedMcpSession; + use mysql_testing::fixtures::mcp_sessions::create_test_mcp_session; + use tokens::tokens::mcp_session_refresh::McpSessionRefreshToken; + + use crate::http_server::endpoints::omni_gen::generate::video::tests::support::TestHarness; + + use super::*; + + #[tokio::test] + #[cfg_attr(feature = "skip_database_tests", ignore)] + async fn valid_refresh_extends_expiry_and_rotates_the_refresh_token() { + let harness = TestHarness::create().await; + let mcp_session = create_session(&harness).await; + + let expiry_before = session_expiry(&harness, &mcp_session).await; + + let response = post_refresh(&harness, &mcp_session, &mcp_session.private_refresh_token) + .await + .expect("valid refresh should succeed"); + assert!(response.success); + assert_ne!( + response.private_refresh_token, + mcp_session.private_refresh_token, + "a successful refresh must rotate the refresh token", + ); + + let expiry_after = session_expiry(&harness, &mcp_session).await; + assert!(expiry_after >= expiry_before, "refresh must not shrink the expiry"); + + // The rotation invalidates the old token (non-idempotent by design)... + let replay = post_refresh(&harness, &mcp_session, &mcp_session.private_refresh_token).await; + assert!( + matches!(replay, Err(CommonWebError::NotAuthorized)), + "a used refresh token must not be replayable", + ); + + // ...while the newly issued token works, rotating again. + let second = post_refresh(&harness, &mcp_session, &response.private_refresh_token) + .await + .expect("the rotated refresh token should work"); + assert_ne!(second.private_refresh_token, response.private_refresh_token); } - Ok(Json(RefreshMcpSessionSuccessResponse { success: true })) + #[tokio::test] + #[cfg_attr(feature = "skip_database_tests", ignore)] + async fn wrong_refresh_token_is_rejected() { + let harness = TestHarness::create().await; + let mcp_session = create_session(&harness).await; + + let wrong_token = McpSessionRefreshToken::generate(); + let result = post_refresh(&harness, &mcp_session, &wrong_token).await; + + assert!(matches!(result, Err(CommonWebError::NotAuthorized))); + } + + #[tokio::test] + #[cfg_attr(feature = "skip_database_tests", ignore)] + async fn refresh_without_a_session_credential_is_rejected() { + let harness = TestHarness::create().await; + let mcp_session = create_session(&harness).await; + + // No Authorization header at all: the refresh token alone must not be enough. + let http_request = TestRequest::post() + .uri("/v1/mcp/session/refresh") + .peer_addr("127.0.0.1:9999".parse().expect("peer addr")) + .to_http_request(); + let request = RefreshMcpSessionRequest { + private_refresh_token: mcp_session.private_refresh_token.clone(), + }; + + let result = refresh_mcp_session_handler( + http_request, + Json(request), + Data::new(harness.server_state.clone()), + ).await; + + assert!(matches!(result, Err(CommonWebError::NotAuthorized))); + } + + async fn create_session(harness: &TestHarness) -> InsertedMcpSession { + let user = harness.create_funded_user(0).await; + create_test_mcp_session(&harness.pool, &user.user_token) + .await + .expect("create mcp session") + } + + /// Drive the real handler as the session, presenting `refresh_token` in the body. + async fn post_refresh( + harness: &TestHarness, + mcp_session: &InsertedMcpSession, + refresh_token: &McpSessionRefreshToken, + ) -> Result { + let http_request = TestRequest::post() + .uri("/v1/mcp/session/refresh") + .insert_header(( + "Authorization", + format!("Bearer {}", mcp_session.private_session_token.as_str()), + )) + .peer_addr("127.0.0.1:9999".parse().expect("peer addr")) + .to_http_request(); + + let request = RefreshMcpSessionRequest { + private_refresh_token: refresh_token.clone(), + }; + + refresh_mcp_session_handler( + http_request, + Json(request), + Data::new(harness.server_state.clone()), + ) + .await + .map(Json::into_inner) + } + + async fn session_expiry( + harness: &TestHarness, + mcp_session: &InsertedMcpSession, + ) -> chrono::DateTime { + use std::marker::PhantomData; + + use mysql_queries::queries::mcp_sessions::get_mcp_session_by_token::{ + get_mcp_session_by_token, GetMcpSessionByTokenArgs, + }; + + let mut connection = harness.pool.acquire().await.expect("acquire for expiry read"); + get_mcp_session_by_token(GetMcpSessionByTokenArgs { + token: &mcp_session.token, + mysql_executor: &mut *connection, + phantom: PhantomData, + }) + .await + .expect("read session") + .expect("session row exists") + .expires_at + } }