refresh tokens

This commit is contained in:
Hanashi
2026-08-27 13:03:27 -04:00
parent 4ebad29664
commit 1a840c74fd
16 changed files with 301 additions and 47 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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)
@@ -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)
@@ -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,
}
@@ -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,
}
@@ -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<InsertedMcpSession, sqlx::Error>
@@ -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,
})
}
@@ -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<u64, sqlx::Error>
) -> Result<Option<McpSessionRefreshToken>, 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))
}
@@ -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.
@@ -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);
@@ -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;
@@ -1509,6 +1509,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
McpSessionPathInfo,
CreateMcpSessionRequest,
CreateMcpSessionSuccessResponse,
RefreshMcpSessionRequest,
RefreshMcpSessionSuccessResponse,
RevokeMcpSessionSuccessResponse,
DeleteMcpSessionSuccessResponse,
@@ -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,
}))
}
@@ -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<RefreshMcpSessionRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<RefreshMcpSessionSuccessResponse>, 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<RefreshMcpSessionSuccessResponse, CommonWebError> {
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<chrono::Utc> {
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
}
}