mcp sessions (#1917)

This commit is contained in:
hanashi authored and GitHub committed 2026-08-27 13:05:55 -04:00
1 parent bde7760080
commit 44ff9ce338
72 files changed
+3128 -176

No files matched your search

@@ -0,0 +1,135 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: McpSessionToken`,\n user_token as `user_token: UserToken`,\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,\n updated_at,\n expires_at,\n maybe_deleted_at\nFROM mcp_sessions\nWHERE user_token = ?\nORDER BY id DESC\nLIMIT ? OFFSET ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: McpSessionToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "maybe_mcp_client_name",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "maybe_mcp_client_version",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "maybe_mcp_client_vendor",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 5,
"name": "ip_address_creation",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 6,
"name": "ip_address_update",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 7,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 8,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 9,
"name": "expires_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 10,
"name": "maybe_deleted_at",
"type_info": {
"type": "Timestamp",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
true,
true,
true,
false,
false,
false,
false,
false,
true
]
},
"hash": "5ad1951163b15a9e241f949f2e66cde830d51d70b395f93000bc47ecb9c2083f"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE mcp_sessions\nSET maybe_deleted_at = NOW()\nWHERE private_session_token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "827eb468e1f732f72e0dc8f655add2b50f12cb933bba230a1fd4abb594a1944a"
}
@@ -0,0 +1,113 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users,\n users.maybe_feature_flags,\n mcp_sessions.token as `mcp_session_token: McpSessionToken`\nFROM mcp_sessions\nINNER JOIN users\n ON users.token = mcp_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE mcp_sessions.private_session_token = ?\n AND mcp_sessions.maybe_deleted_at IS NULL\n AND mcp_sessions.expires_at > NOW()\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "username",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 2,
"name": "display_name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 3,
"name": "email_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "user_role_slug",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 5,
"name": "is_banned",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 6,
"name": "can_ban_users",
"type_info": {
"type": "Tiny",
"flags": "",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 7,
"name": "maybe_feature_flags",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 8,
"name": "mcp_session_token: McpSessionToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
true,
true,
false
]
},
"hash": "847acba63c2ee29b5d59bea4cdd88f12ec4b3e8a4317ab26a1e9039c0b66a1b2"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE mcp_sessions\nSET maybe_deleted_at = NOW()\nWHERE token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "8b61abdfcfcd5c601489521793ef5c8cbe47f408e5c7e9c9a6bf01c9c8a57247"
}
@@ -0,0 +1,135 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: McpSessionToken`,\n user_token as `user_token: UserToken`,\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,\n updated_at,\n expires_at,\n maybe_deleted_at\nFROM mcp_sessions\nWHERE token = ?\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: McpSessionToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "maybe_mcp_client_name",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "maybe_mcp_client_version",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "maybe_mcp_client_vendor",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 5,
"name": "ip_address_creation",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 6,
"name": "ip_address_update",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 7,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 8,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 9,
"name": "expires_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 10,
"name": "maybe_deleted_at",
"type_info": {
"type": "Timestamp",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
true,
true,
false,
false,
false,
false,
false,
true
]
},
"hash": "c59cea297d441b4960074802809af5ff0bdf8969abb34c01101584bffd887010"
}
@@ -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"
}
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS mcp_sessions;
@@ -0,0 +1,65 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- Sessions for MCP and other non-human users.
-- These work like `user_sessions`, but are created and consumed by MCP clients.
CREATE TABLE mcp_sessions (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Non-private "primary key" for the session.
-- Used as a key to delete/manage sessions in the dashboard.
token VARCHAR(32) NOT NULL,
-- 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,
-- ========== CLIENT INFO ==========
-- Trimmed name of the MCP client, if any.
maybe_mcp_client_name VARCHAR(255) DEFAULT NULL,
-- Trimmed version of the MCP client, if any.
maybe_mcp_client_version VARCHAR(255) DEFAULT NULL,
-- Trimmed vendor of the MCP client, if any.
maybe_mcp_client_vendor VARCHAR(255) DEFAULT NULL,
-- ========== IP ADDRESSES ==========
-- IP address that created the session. Wide enough for IPv4/IPv6.
ip_address_creation VARCHAR(40) NOT NULL,
-- IP address that last updated the session. Wide enough for IPv4/IPv6.
ip_address_update VARCHAR(40) NOT NULL,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- When the session expires, if not refreshed.
-- This must be set by the server code, or the session is invalid.
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Soft-delete timestamp. NULL means the session is live.
-- deletion = session termination
maybe_deleted_at TIMESTAMP NULL,
-- INDICES --
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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,65 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- Sessions for MCP and other non-human users.
-- These work like `user_sessions`, but are created and consumed by MCP clients.
CREATE TABLE mcp_sessions (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Non-private "primary key" for the session.
-- Used as a key to delete/manage sessions in the dashboard.
token VARCHAR(32) NOT NULL,
-- 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,
-- ========== CLIENT INFO ==========
-- Trimmed name of the MCP client, if any.
maybe_mcp_client_name VARCHAR(255) DEFAULT NULL,
-- Trimmed version of the MCP client, if any.
maybe_mcp_client_version VARCHAR(255) DEFAULT NULL,
-- Trimmed vendor of the MCP client, if any.
maybe_mcp_client_vendor VARCHAR(255) DEFAULT NULL,
-- ========== IP ADDRESSES ==========
-- IP address that created the session. Wide enough for IPv4/IPv6.
ip_address_creation VARCHAR(40) NOT NULL,
-- IP address that last updated the session. Wide enough for IPv4/IPv6.
ip_address_update VARCHAR(40) NOT NULL,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- When the session expires, if not refreshed.
-- This must be set by the server code, or the session is invalid.
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Soft-delete timestamp. NULL means the session is live.
-- deletion = session termination
maybe_deleted_at TIMESTAMP NULL,
-- INDICES --
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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -7,6 +7,7 @@ pub mod folders;
pub mod generate;
pub mod internal;
pub mod jobs;
pub mod mcp_sessions;
pub mod media_file;
pub mod moderation;
pub mod omni_api;
@@ -0,0 +1,42 @@
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::mcp_sessions::McpSessionToken;
/// Canonical wire shape for an MCP session, used by the dashboard list.
///
/// NB: the secret `private_session_token` is NEVER included here — it is returned exactly once,
/// at creation time. The `token` is the non-private management handle.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct McpSessionInfo {
pub token: McpSessionToken,
/// Trimmed name of the MCP client that created the session, if any.
pub maybe_mcp_client_name: Option<String>,
/// Trimmed version of the MCP client, if any.
pub maybe_mcp_client_version: Option<String>,
/// Trimmed vendor of the MCP client, if any.
pub maybe_mcp_client_vendor: Option<String>,
pub ip_address_creation: String,
pub ip_address_update: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
/// When the session expires, if not refreshed.
pub expires_at: DateTime<Utc>,
/// Termination timestamp. `None` for live (though possibly expired) sessions.
pub maybe_deleted_at: Option<DateTime<Utc>>,
}
/// Path info for endpoints addressed by the session's management `token` (NOT the secret
/// `private_session_token`): `POST /v1/mcp/session/{token}/delete`.
#[derive(Deserialize, ToSchema)]
pub struct McpSessionPathInfo {
pub token: McpSessionToken,
}
@@ -0,0 +1,34 @@
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 ──
#[derive(Deserialize, ToSchema)]
pub struct CreateMcpSessionRequest {
/// Name of the MCP client creating the session, if any.
pub maybe_mcp_client_name: Option<String>,
/// Version of the MCP client, if any.
pub maybe_mcp_client_version: Option<String>,
/// Vendor of the MCP client, if any.
pub maybe_mcp_client_vendor: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct CreateMcpSessionSuccessResponse {
pub success: bool,
/// The secret credential the MCP client presents in the `Authorization` header. This is the
/// 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,
}
@@ -0,0 +1,9 @@
use serde_derive::Serialize;
use utoipa::ToSchema;
// ── POST /v1/mcp/session/{token}/delete ──
#[derive(Serialize, ToSchema)]
pub struct DeleteMcpSessionSuccessResponse {
pub success: bool,
}
@@ -0,0 +1,24 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use crate::mcp_sessions::common::McpSessionInfo;
// ── GET /v1/mcp/session/list ──
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListMcpSessionsQueryParams {
/// Page size. Defaults server-side if omitted.
pub limit: Option<u32>,
/// Number of rows to skip. Defaults to 0.
pub offset: Option<u32>,
}
#[derive(Serialize, ToSchema)]
pub struct ListMcpSessionsSuccessResponse {
pub success: bool,
/// The user's MCP sessions (including terminated ones), newest first. The secret
/// `private_session_token` is never listed.
pub mcp_sessions: Vec<McpSessionInfo>,
}
@@ -0,0 +1,6 @@
pub mod common;
pub mod create_mcp_session;
pub mod delete_mcp_session;
pub mod list_mcp_sessions;
pub mod refresh_mcp_session;
pub mod revoke_mcp_session;
@@ -0,0 +1,22 @@
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,
}
@@ -0,0 +1,9 @@
use serde_derive::Serialize;
use utoipa::ToSchema;
// ── POST /v1/mcp/session/revoke ──
#[derive(Serialize, ToSchema)]
pub struct RevokeMcpSessionSuccessResponse {
pub success: bool,
}
@@ -0,0 +1,40 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::mcp_sessions::McpSessionToken;
pub struct DeleteMcpSessionArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub token: &'e McpSessionToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Terminate a session by its management `token` (the dashboard path), stamping
/// `maybe_deleted_at = NOW()`. Returns the number of rows affected (0 if no live session
/// matched the token). Ownership is NOT checked here — callers verify it first via
/// `get_mcp_session_by_token`.
pub async fn delete_mcp_session<'e, 'c: 'e, E>(
args: DeleteMcpSessionArgs<'e, 'c, E>,
) -> Result<u64, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let result = sqlx::query!(
r#"
UPDATE mcp_sessions
SET maybe_deleted_at = NOW()
WHERE token = ?
AND maybe_deleted_at IS NULL
LIMIT 1
"#,
args.token.as_str(),
)
.execute(args.mysql_executor)
.await?;
Ok(result.rows_affected())
}
@@ -0,0 +1,64 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::mcp_sessions::McpSessionToken;
use tokens::tokens::users::UserToken;
use crate::queries::mcp_sessions::mcp_session_row::McpSessionRow;
pub struct GetMcpSessionByTokenArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub token: &'e McpSessionToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Fetch a session by its management `token` (NOT the private credential), including terminated
/// sessions — callers check ownership and `maybe_deleted_at` themselves. Never returns the
/// `private_session_token`.
pub async fn get_mcp_session_by_token<'e, 'c: 'e, E>(
args: GetMcpSessionByTokenArgs<'e, 'c, E>,
) -> Result<Option<McpSessionRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let maybe_row = sqlx::query!(
r#"
SELECT
token as `token: McpSessionToken`,
user_token as `user_token: UserToken`,
maybe_mcp_client_name,
maybe_mcp_client_version,
maybe_mcp_client_vendor,
ip_address_creation,
ip_address_update,
created_at,
updated_at,
expires_at,
maybe_deleted_at
FROM mcp_sessions
WHERE token = ?
LIMIT 1
"#,
args.token.as_str(),
)
.fetch_optional(args.mysql_executor)
.await?;
Ok(maybe_row.map(|r| McpSessionRow {
token: r.token,
user_token: r.user_token,
maybe_mcp_client_name: r.maybe_mcp_client_name,
maybe_mcp_client_version: r.maybe_mcp_client_version,
maybe_mcp_client_vendor: r.maybe_mcp_client_vendor,
ip_address_creation: r.ip_address_creation,
ip_address_update: r.ip_address_update,
created_at: r.created_at,
updated_at: r.updated_at,
expires_at: r.expires_at,
maybe_deleted_at: r.maybe_deleted_at,
}))
}
@@ -0,0 +1,84 @@
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;
/// How long a freshly created (or refreshed) MCP session lives without a refresh.
pub const MCP_SESSION_LIFETIME_DAYS: u16 = 14;
pub struct InsertMcpSessionArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub user_token: &'e UserToken,
pub ip_address: &'e str,
pub maybe_mcp_client_name: Option<&'e str>,
pub maybe_mcp_client_version: Option<&'e str>,
pub maybe_mcp_client_vendor: Option<&'e str>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// 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. 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>
where
E: 'e + Executor<'c, Database = MySql>,
{
let token = McpSessionToken::generate();
let private_session_token = McpSessionPrivateToken::generate();
let private_refresh_token = McpSessionRefreshToken::generate();
sqlx::query!(
r#"
INSERT INTO mcp_sessions
SET
token = ?,
private_session_token = ?,
private_refresh_token = ?,
user_token = ?,
maybe_mcp_client_name = ?,
maybe_mcp_client_version = ?,
maybe_mcp_client_vendor = ?,
ip_address_creation = ?,
ip_address_update = ?,
created_at = NOW(),
expires_at = NOW() + INTERVAL ? DAY
"#,
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,
args.maybe_mcp_client_vendor,
args.ip_address,
args.ip_address,
MCP_SESSION_LIFETIME_DAYS,
)
.execute(args.mysql_executor)
.await?;
Ok(InsertedMcpSession {
token,
private_session_token,
private_refresh_token,
})
}
@@ -0,0 +1,79 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::mcp_sessions::McpSessionToken;
use tokens::tokens::users::UserToken;
use crate::queries::mcp_sessions::mcp_session_row::McpSessionRow;
pub struct ListMcpSessionsForUserArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub user_token: &'e UserToken,
/// Page size and page offset. Pagination is LIMIT/OFFSET rather than keyset
/// so we don't have to expose the internal `id`.
pub limit: u32,
pub offset: u32,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// List a user's MCP sessions, newest first, **including terminated** sessions (each row carries
/// `maybe_deleted_at` and `expires_at` so callers can filter). Never returns the secret
/// `private_session_token`.
pub async fn list_mcp_sessions_for_user<'e, 'c: 'e, E>(
args: ListMcpSessionsForUserArgs<'e, 'c, E>,
) -> Result<Vec<McpSessionRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let limit = args.limit as i64;
let offset = args.offset as i64;
let rows = sqlx::query!(
r#"
SELECT
token as `token: McpSessionToken`,
user_token as `user_token: UserToken`,
maybe_mcp_client_name,
maybe_mcp_client_version,
maybe_mcp_client_vendor,
ip_address_creation,
ip_address_update,
created_at,
updated_at,
expires_at,
maybe_deleted_at
FROM mcp_sessions
WHERE user_token = ?
ORDER BY id DESC
LIMIT ? OFFSET ?
"#,
args.user_token.as_str(),
limit,
offset,
)
.fetch_all(args.mysql_executor)
.await?
.into_iter()
.map(|r| McpSessionRow {
token: r.token,
user_token: r.user_token,
maybe_mcp_client_name: r.maybe_mcp_client_name,
maybe_mcp_client_version: r.maybe_mcp_client_version,
maybe_mcp_client_vendor: r.maybe_mcp_client_vendor,
ip_address_creation: r.ip_address_creation,
ip_address_update: r.ip_address_update,
created_at: r.created_at,
updated_at: r.updated_at,
expires_at: r.expires_at,
maybe_deleted_at: r.maybe_deleted_at,
})
.collect::<Vec<_>>();
Ok(rows)
}
@@ -0,0 +1,30 @@
use chrono::{DateTime, Utc};
use tokens::tokens::mcp_sessions::McpSessionToken;
use tokens::tokens::users::UserToken;
/// A materialized `mcp_sessions` row, minus the internal `id` and the secret
/// `private_session_token`. The private credential is returned exactly once, at creation time,
/// and is never selected back out of the database.
#[derive(Debug, Clone)]
pub struct McpSessionRow {
pub token: McpSessionToken,
pub user_token: UserToken,
pub maybe_mcp_client_name: Option<String>,
pub maybe_mcp_client_version: Option<String>,
pub maybe_mcp_client_vendor: Option<String>,
pub ip_address_creation: String,
pub ip_address_update: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
/// When the session expires, if not refreshed.
pub expires_at: DateTime<Utc>,
/// Soft-delete (termination) timestamp. `None` for live sessions.
pub maybe_deleted_at: Option<DateTime<Utc>>,
}
@@ -0,0 +1,7 @@
pub mod delete_mcp_session;
pub mod get_mcp_session_by_token;
pub mod insert_mcp_session;
pub mod list_mcp_sessions_for_user;
pub mod mcp_session_row;
pub mod refresh_mcp_session;
pub mod revoke_mcp_session;
@@ -0,0 +1,70 @@
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;
pub struct RefreshMcpSessionArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
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,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// 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<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?;
if result.rows_affected() == 0 {
return Ok(None);
}
Ok(Some(new_refresh_token))
}
@@ -0,0 +1,39 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::mcp_session_private::McpSessionPrivateToken;
pub struct RevokeMcpSessionArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub private_session_token: &'e McpSessionPrivateToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Terminate a session by its own private credential, stamping `maybe_deleted_at = NOW()`.
/// An expired-but-undeleted session may still revoke itself (the end state is the same either
/// way). Returns the number of rows affected (0 = unknown or already-terminated session).
pub async fn revoke_mcp_session<'e, 'c: 'e, E>(
args: RevokeMcpSessionArgs<'e, 'c, E>,
) -> Result<u64, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let result = sqlx::query!(
r#"
UPDATE mcp_sessions
SET maybe_deleted_at = NOW()
WHERE private_session_token = ?
AND maybe_deleted_at IS NULL
LIMIT 1
"#,
args.private_session_token.as_str(),
)
.execute(args.mysql_executor)
.await?;
Ok(result.rows_affected())
}
@@ -22,6 +22,7 @@ pub mod google_sign_in_accounts;
pub mod health_check;
pub mod idepotency_tokens;
pub mod ip_bans;
pub mod mcp_sessions;
pub mod media_files;
pub mod media_uploads;
pub mod model_categories;
@@ -1,11 +1,13 @@
use tokens::tokens::api_keys::ApiKeyToken;
use tokens::tokens::mcp_sessions::McpSessionToken;
use tokens::tokens::users::UserToken;
/// A user authenticated by either an API key or a web session cookie.
/// A user authenticated by an API key, a web session cookie, or an MCP session credential.
///
/// The two lookup queries (`get_api_or_web_session_user_by_api_key` and
/// `get_api_or_web_session_user_by_session_token`) select congruent user fields so both can
/// produce this one uniform record.
/// The lookup queries (`get_api_or_web_session_user_by_api_key`,
/// `get_api_or_web_session_user_by_session_token`, and
/// `get_api_or_web_session_user_by_mcp_private_session_token`) select congruent user fields so
/// all of them can produce this one uniform record.
pub struct ApiOrWebSessionUserRecord {
pub user_token: UserToken,
pub username: String,
@@ -21,4 +23,8 @@ pub struct ApiOrWebSessionUserRecord {
/// Only set when the lookup was by API key.
pub maybe_api_key_token: Option<ApiKeyToken>,
/// Only set when the lookup was by MCP session credential. This is the session's non-private
/// management `token`, never the `private_session_token`.
pub maybe_mcp_session_token: Option<McpSessionToken>,
}
@@ -68,5 +68,6 @@ LIMIT 1
can_ban_users: nullable_i8_to_bool_default_false(r.can_ban_users),
maybe_feature_flags: r.maybe_feature_flags,
maybe_api_key_token: Some(r.api_key_token),
maybe_mcp_session_token: None,
}))
}
@@ -0,0 +1,75 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::mcp_session_private::McpSessionPrivateToken;
use tokens::tokens::mcp_sessions::McpSessionToken;
use tokens::tokens::users::UserToken;
use crate::helpers::boolean_converters::{i8_to_bool, nullable_i8_to_bool_default_false};
use crate::queries::users::api_or_web_sessions::api_or_web_session_user_record::ApiOrWebSessionUserRecord;
pub struct GetApiOrWebSessionUserByMcpPrivateSessionTokenArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
/// The secret MCP session credential presented by the client.
pub private_session_token: &'e McpSessionPrivateToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Look up the user behind an MCP session's private credential, selecting the same user fields
/// as the API-key and web-session lookups so all three auth paths share one record shape.
///
/// Returns `Ok(None)` when no row matches, when the session is terminated or expired, or when
/// the owning user is missing or deleted. The private credential is never echoed back — the
/// record carries only the session's non-private management `token`.
pub async fn get_api_or_web_session_user_by_mcp_private_session_token<'e, 'c: 'e, E>(
args: GetApiOrWebSessionUserByMcpPrivateSessionTokenArgs<'e, 'c, E>,
) -> Result<Option<ApiOrWebSessionUserRecord>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let result = sqlx::query!(
r#"
SELECT
users.token as `user_token: UserToken`,
users.username,
users.display_name,
users.email_address,
users.user_role_slug,
users.is_banned,
user_roles.can_ban_users,
users.maybe_feature_flags,
mcp_sessions.token as `mcp_session_token: McpSessionToken`
FROM mcp_sessions
INNER JOIN users
ON users.token = mcp_sessions.user_token
LEFT OUTER JOIN user_roles
ON users.user_role_slug = user_roles.slug
WHERE mcp_sessions.private_session_token = ?
AND mcp_sessions.maybe_deleted_at IS NULL
AND mcp_sessions.expires_at > NOW()
AND users.user_deleted_at IS NULL
AND users.mod_deleted_at IS NULL
LIMIT 1
"#,
args.private_session_token.as_str(),
)
.fetch_optional(args.mysql_executor)
.await?;
Ok(result.map(|r| ApiOrWebSessionUserRecord {
user_token: r.user_token,
username: r.username,
display_name: r.display_name,
email_address: r.email_address,
user_role_slug: r.user_role_slug,
is_banned: i8_to_bool(r.is_banned),
can_ban_users: nullable_i8_to_bool_default_false(r.can_ban_users),
maybe_feature_flags: r.maybe_feature_flags,
maybe_api_key_token: None,
maybe_mcp_session_token: Some(r.mcp_session_token),
}))
}
@@ -65,5 +65,6 @@ LIMIT 1
can_ban_users: nullable_i8_to_bool_default_false(r.can_ban_users),
maybe_feature_flags: r.maybe_feature_flags,
maybe_api_key_token: None,
maybe_mcp_session_token: None,
}))
}
@@ -1,3 +1,4 @@
pub mod api_or_web_session_user_record;
pub mod get_api_or_web_session_user_by_api_key;
pub mod get_api_or_web_session_user_by_mcp_private_session_token;
pub mod get_api_or_web_session_user_by_session_token;
@@ -0,0 +1,51 @@
//! MCP session fixtures: real `mcp_sessions` rows created via the production
//! insert query, plus test-only mutators for states the production queries
//! never produce (e.g. an already-expired session).
use std::marker::PhantomData;
use anyhow::anyhow;
use sqlx::MySqlPool;
use mysql_queries::queries::mcp_sessions::insert_mcp_session::{
insert_mcp_session, InsertMcpSessionArgs, InsertedMcpSession,
};
use tokens::tokens::mcp_sessions::McpSessionToken;
use tokens::tokens::users::UserToken;
const TEST_IP_ADDRESS: &str = "127.0.0.1";
/// Create a live MCP session for the user via the production insert query.
/// Returns both minted tokens (the management token and the private credential).
pub async fn create_test_mcp_session(
pool: &MySqlPool,
user_token: &UserToken,
) -> anyhow::Result<InsertedMcpSession> {
let mut connection = pool.acquire().await?;
insert_mcp_session(InsertMcpSessionArgs {
user_token,
ip_address: TEST_IP_ADDRESS,
maybe_mcp_client_name: Some("test mcp client"),
maybe_mcp_client_version: None,
maybe_mcp_client_vendor: None,
mysql_executor: &mut *connection,
phantom: PhantomData,
})
.await
.map_err(|err| anyhow!("insert_mcp_session failed: {err:?}"))
}
/// Push a session's `expires_at` into the past. Production queries can never
/// produce an expired-from-birth session, so tests exercising expiry set it
/// directly.
pub async fn force_expire_mcp_session(
pool: &MySqlPool,
token: &McpSessionToken,
) -> anyhow::Result<()> {
sqlx::query("UPDATE mcp_sessions SET expires_at = NOW() - INTERVAL 1 DAY WHERE token = ?")
.bind(token.as_str())
.execute(pool)
.await?;
Ok(())
}
@@ -1,3 +1,4 @@
pub mod mcp_sessions;
pub mod media_files;
pub mod users;
pub mod wallets;
@@ -29,6 +29,9 @@ pub enum PlatformType {
/// Requests authenticated via an `Authorization` header API key (the Omni API surface).
ApiKey,
/// Requests authenticated via an `Authorization` header MCP session credential.
Mcp,
// ========== Other clients ==========
Curl,
@@ -46,6 +49,7 @@ impl PlatformType {
Self::Web => "web",
Self::DesktopApp => "desktop_app",
Self::ApiKey => "api_key",
Self::Mcp => "mcp",
Self::Curl => "curl",
Self::PythonRequests => "python_requests",
Self::Postman => "postman",
@@ -57,6 +61,7 @@ impl PlatformType {
"web" => Ok(Self::Web),
"desktop_app" => Ok(Self::DesktopApp),
"api_key" => Ok(Self::ApiKey),
"mcp" => Ok(Self::Mcp),
"curl" => Ok(Self::Curl),
"python_requests" => Ok(Self::PythonRequests),
"postman" => Ok(Self::Postman),
@@ -70,6 +75,7 @@ impl PlatformType {
Self::Web,
Self::DesktopApp,
Self::ApiKey,
Self::Mcp,
Self::Curl,
Self::PythonRequests,
Self::Postman,
@@ -91,6 +97,7 @@ mod tests {
assert_serialization(PlatformType::Web, "web");
assert_serialization(PlatformType::DesktopApp, "desktop_app");
assert_serialization(PlatformType::ApiKey, "api_key");
assert_serialization(PlatformType::Mcp, "mcp");
assert_serialization(PlatformType::Curl, "curl");
assert_serialization(PlatformType::PythonRequests, "python_requests");
assert_serialization(PlatformType::Postman, "postman");
@@ -101,6 +108,7 @@ mod tests {
assert_eq!(PlatformType::Web.to_str(), "web");
assert_eq!(PlatformType::DesktopApp.to_str(), "desktop_app");
assert_eq!(PlatformType::ApiKey.to_str(), "api_key");
assert_eq!(PlatformType::Mcp.to_str(), "mcp");
assert_eq!(PlatformType::Curl.to_str(), "curl");
assert_eq!(PlatformType::PythonRequests.to_str(), "python_requests");
assert_eq!(PlatformType::Postman.to_str(), "postman");
@@ -111,6 +119,7 @@ mod tests {
assert_eq!(PlatformType::from_str("web").unwrap(), PlatformType::Web);
assert_eq!(PlatformType::from_str("desktop_app").unwrap(), PlatformType::DesktopApp);
assert_eq!(PlatformType::from_str("api_key").unwrap(), PlatformType::ApiKey);
assert_eq!(PlatformType::from_str("mcp").unwrap(), PlatformType::Mcp);
assert_eq!(PlatformType::from_str("curl").unwrap(), PlatformType::Curl);
assert_eq!(PlatformType::from_str("python_requests").unwrap(), PlatformType::PythonRequests);
assert_eq!(PlatformType::from_str("postman").unwrap(), PlatformType::Postman);
@@ -130,10 +139,11 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = PlatformType::all_variants();
assert_eq!(variants.len(), 6);
assert_eq!(variants.len(), 7);
assert_eq!(variants.pop_first(), Some(PlatformType::Web));
assert_eq!(variants.pop_first(), Some(PlatformType::DesktopApp));
assert_eq!(variants.pop_first(), Some(PlatformType::ApiKey));
assert_eq!(variants.pop_first(), Some(PlatformType::Mcp));
assert_eq!(variants.pop_first(), Some(PlatformType::Curl));
assert_eq!(variants.pop_first(), Some(PlatformType::PythonRequests));
assert_eq!(variants.pop_first(), Some(PlatformType::Postman));
@@ -37,6 +37,9 @@ pub(crate) enum TokenPrefix {
Folder,
GoogleSignInAccount,
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,
@@ -130,6 +133,9 @@ impl PrefixGenerator for TokenPrefix {
Self::Folder => "folder_",
Self::GoogleSignInAccount => "gsi_",
Self::InferenceJob => "jinf_",
Self::McpSession => "mcp_",
Self::McpSessionPrivate => "mcp_session_",
Self::McpSessionRefresh => "mcp_refresh_",
Self::MediaFile => "m_",
Self::MediaUpload => "mu_",
Self::ModelWeight => "weight_",
@@ -361,6 +367,8 @@ mod tests {
|| prefix == "pw_reset_"
|| 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,22 @@
use std::fmt::Debug;
use serde::Deserialize;
use serde::Serialize;
use utoipa::ToSchema;
use crate::prefixes::TokenPrefix;
/// The private credential for an `mcp_sessions` row (the `private_session_token` column).
///
/// This is the secret an MCP client presents in the `Authorization` header to authenticate as a
/// live session, so it carries more entropy than an ordinary token. Never list or display it
/// after creation. The non-private management handle is `McpSessionToken` (see
/// `mcp_sessions.rs`).
#[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 McpSessionPrivateToken(pub String);
impl_string_token!(McpSessionPrivateToken);
impl_mysql_token_from_row!(McpSessionPrivateToken);
impl_crockford_generator!(McpSessionPrivateToken, 64usize, TokenPrefix::McpSessionPrivate, CrockfordLower);
@@ -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);
@@ -0,0 +1,21 @@
use std::fmt::Debug;
use serde::Deserialize;
use serde::Serialize;
use utoipa::ToSchema;
use crate::prefixes::TokenPrefix;
/// The primary key for `mcp_sessions`.
///
/// This is the NON-PRIVATE handle for a session — safe to list and display, and used to
/// delete/manage sessions in the dashboard. It never authenticates anything; that is the job of
/// `McpSessionPrivateToken` (see `mcp_session_private.rs`).
#[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 McpSessionToken(pub String);
impl_string_token!(McpSessionToken);
impl_mysql_token_from_row!(McpSessionToken);
impl_crockford_generator!(McpSessionToken, 32usize, TokenPrefix::McpSession, CrockfordLower);
@@ -15,6 +15,9 @@ pub mod folders;
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;
pub mod model_categories;
@@ -122,6 +122,19 @@ use artcraft_api_defs::api_keys::get_api_key::*;
use artcraft_api_defs::api_keys::delete_api_key::*;
use artcraft_api_defs::api_keys::update_api_key::*;
// MCP session endpoints
use crate::http_server::endpoints::mcp_sessions::create_mcp_session_handler::*;
use crate::http_server::endpoints::mcp_sessions::refresh_mcp_session_handler::*;
use crate::http_server::endpoints::mcp_sessions::revoke_mcp_session_handler::*;
use crate::http_server::endpoints::mcp_sessions::delete_mcp_session_handler::*;
use crate::http_server::endpoints::mcp_sessions::list_mcp_sessions_handler::*;
use artcraft_api_defs::mcp_sessions::common::*;
use artcraft_api_defs::mcp_sessions::create_mcp_session::*;
use artcraft_api_defs::mcp_sessions::refresh_mcp_session::*;
use artcraft_api_defs::mcp_sessions::revoke_mcp_session::*;
use artcraft_api_defs::mcp_sessions::delete_mcp_session::*;
use artcraft_api_defs::mcp_sessions::list_mcp_sessions::*;
// Folder endpoints
use crate::http_server::endpoints::folders::folder::color_code_folder_handler::*;
use crate::http_server::endpoints::folders::folder::cover_image_folder_handler::*;
@@ -683,6 +696,13 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::api_keys::delete_api_key_handler::delete_api_key_handler,
crate::http_server::endpoints::api_keys::update_api_key_handler::update_api_key_handler,
// MCP sessions
crate::http_server::endpoints::mcp_sessions::create_mcp_session_handler::create_mcp_session_handler,
crate::http_server::endpoints::mcp_sessions::refresh_mcp_session_handler::refresh_mcp_session_handler,
crate::http_server::endpoints::mcp_sessions::revoke_mcp_session_handler::revoke_mcp_session_handler,
crate::http_server::endpoints::mcp_sessions::delete_mcp_session_handler::delete_mcp_session_handler,
crate::http_server::endpoints::mcp_sessions::list_mcp_sessions_handler::list_mcp_sessions_handler,
// Folders
crate::http_server::endpoints::folders::folder::create_folder_handler::create_folder_handler,
crate::http_server::endpoints::folders::folder::list_folders_handler::list_folders_handler,
@@ -1484,6 +1504,18 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
UpdateApiKeyRequest,
UpdateApiKeySuccessResponse,
// MCP sessions
McpSessionInfo,
McpSessionPathInfo,
CreateMcpSessionRequest,
CreateMcpSessionSuccessResponse,
RefreshMcpSessionRequest,
RefreshMcpSessionSuccessResponse,
RevokeMcpSessionSuccessResponse,
DeleteMcpSessionSuccessResponse,
ListMcpSessionsQueryParams,
ListMcpSessionsSuccessResponse,
// Folders
FolderInfo,
FolderThumbnail,
@@ -0,0 +1,103 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::mcp_sessions::create_mcp_session::{
CreateMcpSessionRequest, CreateMcpSessionSuccessResponse,
};
use http_server_common::request::get_request_ip::get_request_ip;
use mysql_queries::queries::mcp_sessions::insert_mcp_session::{
insert_mcp_session, InsertMcpSessionArgs,
};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::require_api_or_web_session;
use crate::state::server_state::ServerState;
const MAX_CLIENT_FIELD_LEN: usize = 255;
/// Create a new MCP session for the authenticated user (API key or web session). The secret
/// `private_session_token` is returned exactly once in this response — it can never be
/// retrieved again afterward.
#[utoipa::path(
post,
tag = "MCP Sessions",
path = "/v1/mcp/session/create",
request_body = CreateMcpSessionRequest,
responses(
(status = 200, body = CreateMcpSessionSuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn create_mcp_session_handler(
http_request: HttpRequest,
request: Json<CreateMcpSessionRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<CreateMcpSessionSuccessResponse>, CommonWebError> {
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
warn!("MySQL pool error: {:?}", err);
CommonWebError::from_error(err)
})?;
let user_session = require_api_or_web_session(
&http_request,
&server_state.session_checker,
&server_state.avt_cookie_manager,
&mut *conn,
).await?;
let maybe_client_name = validated_client_field("client name", &request.maybe_mcp_client_name)?;
let maybe_client_version = validated_client_field("client version", &request.maybe_mcp_client_version)?;
let maybe_client_vendor = validated_client_field("client vendor", &request.maybe_mcp_client_vendor)?;
let ip_address = get_request_ip(&http_request);
let inserted = insert_mcp_session(InsertMcpSessionArgs {
user_token: &user_session.user_token,
ip_address: &ip_address,
maybe_mcp_client_name: maybe_client_name.as_deref(),
maybe_mcp_client_version: maybe_client_version.as_deref(),
maybe_mcp_client_vendor: maybe_client_vendor.as_deref(),
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("insert_mcp_session failed: {:?}", err);
CommonWebError::from_error(err)
})?;
Ok(Json(CreateMcpSessionSuccessResponse {
success: true,
// 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,
}))
}
/// Trim an optional client-supplied field; an empty (or whitespace-only) value becomes `None`,
/// and an overlong value is a 400.
fn validated_client_field(
field_name: &str,
maybe_value: &Option<String>,
) -> Result<Option<String>, CommonWebError> {
let trimmed = match maybe_value {
Some(value) => value.trim(),
None => return Ok(None),
};
if trimmed.is_empty() {
return Ok(None);
}
if trimmed.len() > MAX_CLIENT_FIELD_LEN {
return Err(CommonWebError::BadInputWithSimpleMessage(
format!("{} too long (max {} chars)", field_name, MAX_CLIENT_FIELD_LEN),
));
}
Ok(Some(trimmed.to_string()))
}
@@ -0,0 +1,82 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::mcp_sessions::common::McpSessionPathInfo;
use artcraft_api_defs::mcp_sessions::delete_mcp_session::DeleteMcpSessionSuccessResponse;
use mysql_queries::queries::mcp_sessions::delete_mcp_session::{
delete_mcp_session, DeleteMcpSessionArgs,
};
use mysql_queries::queries::mcp_sessions::get_mcp_session_by_token::{
get_mcp_session_by_token, GetMcpSessionByTokenArgs,
};
use tokens::tokens::mcp_sessions::McpSessionToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::require_api_or_web_session;
use crate::state::server_state::ServerState;
/// Terminate an MCP session by its management `token`, scoped to the authenticated user (API
/// key or web session). This is the dashboard path; a session revoking ITSELF uses
/// `/v1/mcp/session/revoke` with its private credential instead.
#[utoipa::path(
post,
tag = "MCP Sessions",
path = "/v1/mcp/session/{token}/delete",
params(("token" = McpSessionToken, description = "The session's management token (not the private credential)")),
responses(
(status = 200, body = DeleteMcpSessionSuccessResponse),
(status = 401, body = CommonWebError),
(status = 404, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn delete_mcp_session_handler(
http_request: HttpRequest,
path: Path<McpSessionPathInfo>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<DeleteMcpSessionSuccessResponse>, CommonWebError> {
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
warn!("MySQL pool error: {:?}", err);
CommonWebError::from_error(err)
})?;
let user_session = require_api_or_web_session(
&http_request,
&server_state.session_checker,
&server_state.avt_cookie_manager,
&mut *conn,
).await?;
// The by-token delete query isn't owner-scoped, so first look the session up and confirm
// ownership. Return NotFound for missing OR other-owned sessions, so we never leak the
// existence of another user's session.
let row = get_mcp_session_by_token(GetMcpSessionByTokenArgs {
token: &path.token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("get_mcp_session_by_token failed: {:?}", err);
CommonWebError::from_error(err)
})?
.filter(|row| row.user_token == user_session.user_token)
.ok_or(CommonWebError::NotFound)?;
// Only issue the delete if the session is still live. If it's already terminated the desired
// end state already holds, so treat it as success.
if row.maybe_deleted_at.is_none() {
delete_mcp_session(DeleteMcpSessionArgs {
token: &path.token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("delete_mcp_session failed: {:?}", err);
CommonWebError::from_error(err)
})?;
}
Ok(Json(DeleteMcpSessionSuccessResponse { success: true }))
}
@@ -0,0 +1,74 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::{Json, Query};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::mcp_sessions::list_mcp_sessions::{
ListMcpSessionsQueryParams, ListMcpSessionsSuccessResponse,
};
use mysql_queries::queries::mcp_sessions::list_mcp_sessions_for_user::{
list_mcp_sessions_for_user, ListMcpSessionsForUserArgs,
};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::mcp_sessions::mcp_session_info_conversion::mcp_session_row_to_info;
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::require_api_or_web_session;
use crate::state::server_state::ServerState;
const DEFAULT_LIMIT: u32 = 100;
const MAX_LIMIT: u32 = 1000;
/// List the authenticated user's MCP sessions (API key or web session), newest first,
/// **including terminated** sessions. Paginated via `limit`/`offset`. Never returns the secret
/// `private_session_token` — only the management `token`.
#[utoipa::path(
get,
tag = "MCP Sessions",
path = "/v1/mcp/session/list",
params(ListMcpSessionsQueryParams),
responses(
(status = 200, body = ListMcpSessionsSuccessResponse),
(status = 401, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn list_mcp_sessions_handler(
http_request: HttpRequest,
query: Query<ListMcpSessionsQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListMcpSessionsSuccessResponse>, CommonWebError> {
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
warn!("MySQL pool error: {:?}", err);
CommonWebError::from_error(err)
})?;
let user_session = require_api_or_web_session(
&http_request,
&server_state.session_checker,
&server_state.avt_cookie_manager,
&mut *conn,
).await?;
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
let offset = query.offset.unwrap_or(0);
let rows = list_mcp_sessions_for_user(ListMcpSessionsForUserArgs {
user_token: &user_session.user_token,
limit,
offset,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("list_mcp_sessions_for_user failed: {:?}", err);
CommonWebError::from_error(err)
})?;
let mcp_sessions = rows.into_iter().map(mcp_session_row_to_info).collect();
Ok(Json(ListMcpSessionsSuccessResponse {
success: true,
mcp_sessions,
}))
}
@@ -0,0 +1,19 @@
use artcraft_api_defs::mcp_sessions::common::McpSessionInfo;
use mysql_queries::queries::mcp_sessions::mcp_session_row::McpSessionRow;
/// Map a database row onto the wire shape. The row never contains the secret
/// `private_session_token`, so nothing here can leak it.
pub fn mcp_session_row_to_info(row: McpSessionRow) -> McpSessionInfo {
McpSessionInfo {
token: row.token,
maybe_mcp_client_name: row.maybe_mcp_client_name,
maybe_mcp_client_version: row.maybe_mcp_client_version,
maybe_mcp_client_vendor: row.maybe_mcp_client_vendor,
ip_address_creation: row.ip_address_creation,
ip_address_update: row.ip_address_update,
created_at: row.created_at,
updated_at: row.updated_at,
expires_at: row.expires_at,
maybe_deleted_at: row.maybe_deleted_at,
}
}
@@ -0,0 +1,6 @@
pub mod create_mcp_session_handler;
pub mod delete_mcp_session_handler;
pub mod list_mcp_sessions_handler;
pub mod mcp_session_info_conversion;
pub mod refresh_mcp_session_handler;
pub mod revoke_mcp_session_handler;
@@ -0,0 +1,233 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::warn;
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,
};
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, 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),
(status = 500, body = CommonWebError),
),
)]
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 =
get_authorization_header_mcp_private_session_token(&http_request)
.ok_or_else(|| {
warn!("MCP session refresh without a usable MCP session credential");
CommonWebError::NotAuthorized
})?;
let ip_address = get_request_ip(&http_request);
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
warn!("MySQL pool error: {:?}", err);
CommonWebError::from_error(err)
})?;
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,
}).await.map_err(|err| {
warn!("refresh_mcp_session failed: {:?}", err);
CommonWebError::from_error(err)
})?;
// 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);
}
#[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
}
}
@@ -0,0 +1,62 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::mcp_sessions::revoke_mcp_session::RevokeMcpSessionSuccessResponse;
use mysql_queries::queries::mcp_sessions::revoke_mcp_session::{
revoke_mcp_session, RevokeMcpSessionArgs,
};
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;
/// Terminate the calling MCP session (authenticated ONLY by its `private_session_token` in the
/// `Authorization` header), marking it invalid for all future use. Sessions are not
/// recoverable.
#[utoipa::path(
post,
tag = "MCP Sessions",
path = "/v1/mcp/session/revoke",
responses(
(status = 200, body = RevokeMcpSessionSuccessResponse),
(status = 401, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn revoke_mcp_session_handler(
http_request: HttpRequest,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<RevokeMcpSessionSuccessResponse>, CommonWebError> {
let private_session_token =
get_authorization_header_mcp_private_session_token(&http_request)
.ok_or_else(|| {
warn!("MCP session revoke without a usable MCP session credential");
CommonWebError::NotAuthorized
})?;
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
warn!("MySQL pool error: {:?}", err);
CommonWebError::from_error(err)
})?;
let revoked_rows = revoke_mcp_session(RevokeMcpSessionArgs {
private_session_token: &private_session_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("revoke_mcp_session failed: {:?}", err);
CommonWebError::from_error(err)
})?;
// An unknown or already-terminated session is a 401, not a leak of which case occurred.
if revoked_rows == 0 {
warn!("No revocable MCP session for presented credential");
return Err(CommonWebError::NotAuthorized);
}
Ok(Json(RevokeMcpSessionSuccessResponse { success: true }))
}
@@ -1 +1,2 @@
pub mod read_upload_session;
pub mod try_parse_generation_provider;
@@ -0,0 +1,312 @@
use actix_web::http::header;
use actix_web::HttpRequest;
use log::{error, warn};
use sqlx::pool::PoolConnection;
use sqlx::MySql;
use enums::common::visibility::Visibility;
use mysql_queries::queries::users::user_sessions::get_user_session_by_token::SessionUserRecord;
use tokens::tokens::users::UserToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
use crate::http_server::user_lookup::api_or_web_session::require_any_session_or_key::{
require_any_session_or_key, AnySession,
};
use crate::state::server_state::ServerState;
/// The optionally-authenticated identity behind a media file upload.
///
/// Uploads historically allow anonymous (cookie-less) callers, so the cookie path stays
/// OPTIONAL. Header credentials (API keys and MCP sessions) are different: presenting one is an
/// explicit claim of identity, so a bad credential is a 401, never an anonymous fallthrough.
pub struct UploadSessionAuth {
/// Present when the request authenticated via an `Authorization` header credential (an API
/// key or an MCP session).
pub maybe_header_session: Option<AnySession>,
/// Present when the request carried a valid web-session cookie (and no `Authorization`
/// header).
pub maybe_cookie_session: Option<SessionUserRecord>,
}
impl UploadSessionAuth {
/// The uploading user, whichever auth path identified them. `None` = anonymous upload.
pub fn maybe_user_token(&self) -> Option<&UserToken> {
self.maybe_header_session
.as_ref()
.map(|session| &session.user_token)
.or_else(|| {
self.maybe_cookie_session
.as_ref()
.map(|session| session.get_user_token())
})
}
pub fn is_logged_in(&self) -> bool {
self.maybe_header_session.is_some() || self.maybe_cookie_session.is_some()
}
/// The user's preferred result visibility. Only web-session lookups carry preferences;
/// header-authenticated (API key / MCP) uploads fall back to the caller's default.
pub fn maybe_preferred_visibility(&self) -> Option<Visibility> {
self.maybe_cookie_session
.as_ref()
.map(|session| session.preferred_tts_result_visibility)
}
}
/// Read the upload's identity: a REQUIRED `Authorization` header credential (API key or MCP
/// session) when one is present, otherwise an OPTIONAL web-session cookie. Banned users are
/// rejected on both paths.
pub async fn read_upload_session(
http_request: &HttpRequest,
server_state: &ServerState,
mysql_connection: &mut PoolConnection<MySql>,
) -> Result<UploadSessionAuth, MediaFileUploadError> {
if http_request.headers().contains_key(header::AUTHORIZATION) {
let session = require_any_session_or_key(
http_request,
&server_state.session_checker,
&server_state.avt_cookie_manager,
&mut **mysql_connection,
)
.await
.map_err(|err| match err {
CommonWebError::NotAuthorized => MediaFileUploadError::NotAuthorized,
other => {
error!("Header credential lookup error: {:?}", other);
MediaFileUploadError::ServerError
}
})?;
return Ok(UploadSessionAuth {
maybe_header_session: Some(session),
maybe_cookie_session: None,
});
}
let maybe_cookie_session = server_state
.session_checker
.maybe_get_user_session_from_connection(http_request, mysql_connection)
.await
.map_err(|err| {
error!("Session checker error: {:?}", err);
MediaFileUploadError::ServerError
})?;
if let Some(ref user) = maybe_cookie_session {
if user.is_banned {
warn!("user is banned: {:?}", user.get_user_token().as_str());
return Err(MediaFileUploadError::NotAuthorizedVerbose("user is banned".to_string()));
}
}
Ok(UploadSessionAuth {
maybe_header_session: None,
maybe_cookie_session,
})
}
#[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 helper with
//! dummy Actix HTTP requests against a test `ServerState`. 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
//! ```
//!
//! They run in PARALLEL: every test creates its own users, API keys, and MCP sessions.
use std::marker::PhantomData;
use actix_web::cookie::Cookie;
use actix_web::test::TestRequest;
use actix_web::HttpRequest;
use mysql_queries::queries::mcp_sessions::revoke_mcp_session::{
revoke_mcp_session, RevokeMcpSessionArgs,
};
use mysql_testing::fixtures::users::{create_test_user, TestUser};
use crate::http_server::endpoints::omni_gen::generate::video::tests::support::TestHarness;
use crate::http_server::user_lookup::api_or_web_session::require_any_session_or_key::AnySessionType;
use super::*;
mod anonymous_and_cookie_lookups {
use super::*;
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn no_credentials_is_an_anonymous_upload() {
let harness = TestHarness::create().await;
let request = TestRequest::default().to_http_request();
let session_auth = read(&harness, &request).await.expect("anonymous should be allowed");
assert!(!session_auth.is_logged_in());
assert!(session_auth.maybe_user_token().is_none());
assert!(session_auth.maybe_header_session.is_none());
assert!(session_auth.maybe_cookie_session.is_none());
assert!(session_auth.maybe_preferred_visibility().is_none());
}
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn web_session_cookie_identifies_the_user() {
let harness = TestHarness::create().await;
let user = create_user(&harness).await;
let request = request_with_session_cookie(&harness, &user);
let session_auth = read(&harness, &request).await.expect("cookie session should be read");
assert!(session_auth.is_logged_in());
assert_eq!(session_auth.maybe_user_token(), Some(&user.user_token));
assert!(session_auth.maybe_header_session.is_none());
// Cookie sessions carry the user's preferences.
assert!(session_auth.maybe_preferred_visibility().is_some());
}
}
mod header_credential_lookups {
use super::*;
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn api_key_identifies_the_user() {
let harness = TestHarness::create().await;
let user = create_user(&harness).await;
let api_key = harness.create_api_key(&user).await;
let request = request_with_bearer(api_key.as_str_be_careful());
let session_auth = read(&harness, &request).await.expect("API key should authenticate");
assert!(session_auth.is_logged_in());
assert_eq!(session_auth.maybe_user_token(), Some(&user.user_token));
let header_session = session_auth.maybe_header_session.as_ref().expect("header session");
assert_eq!(header_session.session_type, AnySessionType::Api);
assert!(header_session.maybe_api_key_token.is_some());
// Header lookups don't carry user preferences.
assert!(session_auth.maybe_preferred_visibility().is_none());
}
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn mcp_session_identifies_the_user() {
let harness = TestHarness::create().await;
let user = create_user(&harness).await;
let credential = harness.create_mcp_session(&user).await;
let request = request_with_bearer(credential.as_str());
let session_auth = read(&harness, &request).await.expect("MCP session should authenticate");
assert!(session_auth.is_logged_in());
assert_eq!(session_auth.maybe_user_token(), Some(&user.user_token));
let header_session = session_auth.maybe_header_session.as_ref().expect("header session");
assert_eq!(header_session.session_type, AnySessionType::McpSession);
assert!(header_session.maybe_mcp_session_token.is_some());
assert!(session_auth.maybe_preferred_visibility().is_none());
}
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn header_credential_wins_over_a_session_cookie() {
let harness = TestHarness::create().await;
let cookie_user = create_user(&harness).await;
let api_key_user = create_user(&harness).await;
let api_key = harness.create_api_key(&api_key_user).await;
let cookie = session_cookie(&harness, &cookie_user);
let request = TestRequest::default()
.insert_header(("Authorization", format!("Bearer {}", api_key.as_str_be_careful())))
.cookie(cookie)
.to_http_request();
let session_auth = read(&harness, &request).await.expect("header credential should win");
assert_eq!(session_auth.maybe_user_token(), Some(&api_key_user.user_token));
assert!(session_auth.maybe_cookie_session.is_none());
}
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn unknown_header_credentials_are_rejected_not_anonymous() {
let harness = TestHarness::create().await;
for credential in [
"artcraft_api_no_such_key_0000000000000000",
"mcp_session_no_such_session_00000000000000000000000000000000",
] {
let request = request_with_bearer(credential);
let result = read(&harness, &request).await;
assert!(
matches!(result, Err(MediaFileUploadError::NotAuthorized)),
"bad credential {credential:?} must be a 401, not an anonymous fallthrough",
);
}
}
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn revoked_mcp_session_is_rejected() {
let harness = TestHarness::create().await;
let user = create_user(&harness).await;
let credential = harness.create_mcp_session(&user).await;
let mut connection = harness.pool.acquire().await.expect("acquire for revoke");
let revoked = revoke_mcp_session(RevokeMcpSessionArgs {
private_session_token: &credential,
mysql_executor: &mut *connection,
phantom: PhantomData,
})
.await
.expect("revoke mcp session");
assert_eq!(revoked, 1);
let request = request_with_bearer(credential.as_str());
let result = read(&harness, &request).await;
assert!(matches!(result, Err(MediaFileUploadError::NotAuthorized)));
}
}
/// Run the helper under test against a fresh connection from the test pool.
async fn read(
harness: &TestHarness,
request: &HttpRequest,
) -> Result<UploadSessionAuth, MediaFileUploadError> {
let mut connection = harness.pool.acquire().await.expect("acquire for lookup");
read_upload_session(request, &harness.server_state, &mut connection).await
}
async fn create_user(harness: &TestHarness) -> TestUser {
create_test_user(&harness.pool).await.expect("create test user")
}
/// A dummy HTTP request presenting `credential` as a Bearer Authorization header.
fn request_with_bearer(credential: &str) -> HttpRequest {
TestRequest::default()
.insert_header(("Authorization", format!("Bearer {credential}")))
.to_http_request()
}
/// A dummy HTTP request carrying the user's session cookie and no Authorization header.
fn request_with_session_cookie(harness: &TestHarness, user: &TestUser) -> HttpRequest {
TestRequest::default()
.cookie(session_cookie(harness, user))
.to_http_request()
}
/// The user's session cookie, owned so TestRequest can take it.
fn session_cookie(harness: &TestHarness, user: &TestUser) -> Cookie<'static> {
let cookie = harness
.server_state
.session_cookie_manager
.create_cookie(&user.session_token, &user.user_token)
.expect("create session cookie");
Cookie::new("session", cookie.value().to_string())
}
}
@@ -25,6 +25,7 @@ use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_
use mysql_queries::queries::media_files::create::specialized_insert::insert_media_file_from_file_upload::{insert_media_file_from_file_upload, InsertMediaFileFromUploadArgs, UploadType};
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::endpoints::media_files::upload::common_utils::read_upload_session::read_upload_session;
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
use crate::http_server::endpoints::media_files::upload::common_utils::try_parse_generation_provider::try_parse_generation_provider;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
@@ -124,36 +125,22 @@ pub async fn upload_audio_media_file_handler(
// ==================== READ SESSION ==================== //
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
.await
.map_err(|e| {
error!("Session checker error: {:?}", e);
MediaFileUploadError::ServerError
})?;
// API-key / MCP-session (Authorization header) or optional web-session (cookie) callers;
// banned users are rejected inside.
let session_auth = read_upload_session(&http_request, &server_state, &mut mysql_connection).await?;
let maybe_user_token = maybe_user_session
.as_ref()
.map(|session| session.get_user_token());
let maybe_user_token = session_auth.maybe_user_token();
let maybe_avt_token = server_state
.avt_cookie_manager
.get_avt_token_from_request(&http_request);
// ==================== BANNED USERS ==================== //
if let Some(ref user) = maybe_user_session {
if user.is_banned {
return Err(MediaFileUploadError::NotAuthorizedVerbose("user is banned".to_string()));
}
}
// ==================== RATE LIMIT ==================== //
let rate_limiter = match maybe_user_session {
None => &server_state.redis_rate_limiters.file_upload_logged_out,
Some(ref _session) => &server_state.redis_rate_limiters.file_upload_logged_in,
let rate_limiter = if session_auth.is_logged_in() {
&server_state.redis_rate_limiters.file_upload_logged_in
} else {
&server_state.redis_rate_limiters.file_upload_logged_out
};
if let Err(_err) = rate_limiter.rate_limit_request(&http_request).await {
@@ -184,11 +171,7 @@ pub async fn upload_audio_media_file_handler(
let creator_set_visibility = form.maybe_visibility
.map(|visibility| visibility.0)
.or_else(|| {
maybe_user_session
.as_ref()
.map(|user_session| user_session.preferred_tts_result_visibility)
})
.or_else(|| session_auth.maybe_preferred_visibility())
.unwrap_or(Visibility::default());
// ==================== USER DATA ==================== //
@@ -20,6 +20,7 @@ use mysql_queries::queries::media_files::create::insert_builder::media_file_inse
use mysql_queries::queries::media_files::create::specialized_insert::insert_media_file_from_file_upload::{insert_media_file_from_file_upload, InsertMediaFileFromUploadArgs, UploadType};
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::endpoints::media_files::upload::common_utils::read_upload_session::read_upload_session;
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
use crate::http_server::endpoints::media_files::upload::upload_generic::drain_multipart_request::{drain_multipart_request, MediaFileUploadSource};
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
@@ -65,32 +66,20 @@ pub async fn process_upload_media_file(
// ==================== READ SESSION ==================== //
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session_from_connection(http_request, &mut mysql_connection)
.await
.map_err(|e| {
error!("Session checker error: {:?}", e);
MediaFileUploadError::ServerError
})?;
// API-key / MCP-session (Authorization header) or optional web-session (cookie) callers;
// banned users are rejected inside.
let session_auth = read_upload_session(http_request, server_state, &mut mysql_connection).await?;
let maybe_avt_token = server_state
.avt_cookie_manager
.get_avt_token_from_request(&http_request);
// ==================== BANNED USERS ==================== //
if let Some(ref user) = maybe_user_session {
if user.is_banned {
return Err(MediaFileUploadError::NotAuthorized);
}
}
// ==================== RATE LIMIT ==================== //
let rate_limiter = match maybe_user_session {
None => &server_state.redis_rate_limiters.file_upload_logged_out,
Some(ref _session) => &server_state.redis_rate_limiters.file_upload_logged_in,
let rate_limiter = if session_auth.is_logged_in() {
&server_state.redis_rate_limiters.file_upload_logged_in
} else {
&server_state.redis_rate_limiters.file_upload_logged_out
};
if let Err(_err) = rate_limiter.rate_limit_request(&http_request).await {
@@ -132,18 +121,12 @@ pub async fn process_upload_media_file(
error!("Invalid visibility: {:?}", err);
MediaFileUploadError::BadInput("invalid visibility".to_string())
})?
.or_else(|| {
maybe_user_session
.as_ref()
.map(|user_session| user_session.preferred_tts_result_visibility)
})
.or_else(|| session_auth.maybe_preferred_visibility())
.unwrap_or(Visibility::default());
let ip_address = get_request_ip(&http_request);
let maybe_user_token = maybe_user_session
.as_ref()
.map(|session| session.get_user_token());
let maybe_user_token = session_auth.maybe_user_token();
let maybe_file_size_bytes = upload_media_request.file_bytes
.as_ref()
@@ -12,6 +12,7 @@ use log::{error, info, warn};
use once_cell::sync::Lazy;
use utoipa::ToSchema;
use crate::http_server::endpoints::media_files::upload::common_utils::read_upload_session::read_upload_session;
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
use crate::http_server::endpoints::media_files::upload::common_utils::try_parse_generation_provider::try_parse_generation_provider;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
@@ -134,38 +135,24 @@ pub async fn upload_image_media_file_handler(
// ==================== READ SESSION ==================== //
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
.await
.map_err(|e| {
error!("Session checker error: {:?}", e);
MediaFileUploadError::ServerError
})?;
// API-key / MCP-session (Authorization header) or optional web-session (cookie) callers;
// banned users are rejected inside.
let session_auth = read_upload_session(&http_request, &server_state, &mut mysql_connection).await?;
let maybe_user_token = maybe_user_session
.as_ref()
.map(|session| session.get_user_token());
let maybe_user_token = session_auth.maybe_user_token();
let maybe_avt_token = server_state
.avt_cookie_manager
.get_avt_token_from_request(&http_request);
// ==================== BANNED USERS ==================== //
if let Some(ref user) = maybe_user_session {
if user.is_banned {
return Err(MediaFileUploadError::NotAuthorizedVerbose("user is banned".to_string()));
}
}
// ==================== RATE LIMIT ==================== //
// NB: We upload batches of images now, so a rate limiter doesn't make much sense anymore.
//
// let rate_limiter = match maybe_user_session {
// None => &server_state.redis_rate_limiters.file_upload_logged_out,
// Some(ref _session) => &server_state.redis_rate_limiters.file_upload_logged_in,
// let rate_limiter = if session_auth.is_logged_in() {
// &server_state.redis_rate_limiters.file_upload_logged_in
// } else {
// &server_state.redis_rate_limiters.file_upload_logged_out
// };
//
// if let Err(_err) = rate_limiter.rate_limit_request(&http_request) {
@@ -194,11 +181,7 @@ pub async fn upload_image_media_file_handler(
let creator_set_visibility = form.maybe_visibility
.map(|visibility| visibility.0)
.or_else(|| {
maybe_user_session
.as_ref()
.map(|user_session| user_session.preferred_tts_result_visibility)
})
.or_else(|| session_auth.maybe_preferred_visibility())
.unwrap_or(Visibility::default());
// ==================== USER DATA ==================== //
@@ -23,6 +23,7 @@ use mysql_queries::queries::media_files::create::insert_builder::media_file_inse
use mysql_queries::queries::media_files::create::specialized_insert::insert_media_file_from_file_upload::{insert_media_file_from_file_upload, InsertMediaFileFromUploadArgs, UploadType};
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::endpoints::media_files::upload::common_utils::read_upload_session::read_upload_session;
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
use crate::http_server::endpoints::media_files::upload::common_utils::try_parse_generation_provider::try_parse_generation_provider;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
@@ -108,36 +109,22 @@ pub async fn upload_spz_media_file_handler(
// ==================== READ SESSION ==================== //
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
.await
.map_err(|e| {
error!("Session checker error: {:?}", e);
MediaFileUploadError::ServerError
})?;
// API-key / MCP-session (Authorization header) or optional web-session (cookie) callers;
// banned users are rejected inside.
let session_auth = read_upload_session(&http_request, &server_state, &mut mysql_connection).await?;
let maybe_user_token = maybe_user_session
.as_ref()
.map(|session| session.get_user_token());
let maybe_user_token = session_auth.maybe_user_token();
let maybe_avt_token = server_state
.avt_cookie_manager
.get_avt_token_from_request(&http_request);
// ==================== BANNED USERS ==================== //
if let Some(ref user) = maybe_user_session {
if user.is_banned {
return Err(MediaFileUploadError::NotAuthorizedVerbose("user is banned".to_string()));
}
}
// ==================== RATE LIMIT ==================== //
let rate_limiter = match maybe_user_session {
None => &server_state.redis_rate_limiters.file_upload_logged_out,
Some(ref _session) => &server_state.redis_rate_limiters.file_upload_logged_in,
let rate_limiter = if session_auth.is_logged_in() {
&server_state.redis_rate_limiters.file_upload_logged_in
} else {
&server_state.redis_rate_limiters.file_upload_logged_out
};
if let Err(_err) = rate_limiter.rate_limit_request(&http_request).await {
@@ -167,11 +154,7 @@ pub async fn upload_spz_media_file_handler(
let creator_set_visibility = form.maybe_visibility
.map(|visibility| visibility.0)
.or_else(|| {
maybe_user_session
.as_ref()
.map(|user_session| user_session.preferred_tts_result_visibility)
})
.or_else(|| session_auth.maybe_preferred_visibility())
.unwrap_or(Visibility::default());
// ==================== USER DATA ==================== //
@@ -32,6 +32,7 @@ use thumbnail_generator::task_client::thumbnail_task::{ThumbnailTaskBuilder, Thu
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use crate::http_server::endpoints::media_files::upload::common_utils::read_upload_session::read_upload_session;
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
use crate::http_server::endpoints::media_files::upload::common_utils::try_parse_generation_provider::try_parse_generation_provider;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
@@ -166,36 +167,22 @@ pub async fn upload_new_video_media_file_handler(
MediaFileUploadError::ServerError
})?;
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
.await
.map_err(|e| {
error!("Session checker error: {:?}", e);
MediaFileUploadError::ServerError
})?;
// API-key / MCP-session (Authorization header) or optional web-session (cookie) callers;
// banned users are rejected inside.
let session_auth = read_upload_session(&http_request, &server_state, &mut mysql_connection).await?;
let maybe_user_token = maybe_user_session
.as_ref()
.map(|session| session.get_user_token());
let maybe_user_token = session_auth.maybe_user_token();
let maybe_avt_token = server_state
.avt_cookie_manager
.get_avt_token_from_request(&http_request);
// ==================== BANNED USERS ==================== //
if let Some(ref user) = maybe_user_session {
if user.is_banned {
return Err(MediaFileUploadError::NotAuthorizedVerbose("user is banned".to_string()));
}
}
// ==================== RATE LIMIT ==================== //
let rate_limiter = match maybe_user_session {
None => &server_state.redis_rate_limiters.file_upload_logged_out,
Some(ref _session) => &server_state.redis_rate_limiters.file_upload_logged_in,
let rate_limiter = if session_auth.is_logged_in() {
&server_state.redis_rate_limiters.file_upload_logged_in
} else {
&server_state.redis_rate_limiters.file_upload_logged_out
};
if let Err(_err) = rate_limiter.rate_limit_request(&http_request).await {
@@ -226,11 +213,7 @@ pub async fn upload_new_video_media_file_handler(
let creator_set_visibility = form.maybe_visibility
.map(|visibility| visibility.0)
.or_else(|| {
maybe_user_session
.as_ref()
.map(|user_session| user_session.preferred_tts_result_visibility)
})
.or_else(|| session_auth.maybe_preferred_visibility())
.unwrap_or(Visibility::default());
// ==================== USER DATA ==================== //
@@ -14,6 +14,7 @@ pub mod generate;
pub mod image_studio;
pub mod inference_job;
pub mod internal;
pub mod mcp_sessions;
pub mod media_files;
pub mod misc;
pub mod model_download;
@@ -39,14 +39,15 @@ use crate::http_server::endpoints::omni_gen::generate::audio::pipeline_v2::run_p
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs;
use crate::http_server::endpoints::omni_gen::shared_utils::kinovi_account::KinoviAccount;
use crate::http_server::endpoints::omni_gen::shared_utils::audio::validate_audio_request::validate_audio_request;
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::{require_api_or_web_session, SessionType};
use crate::http_server::user_lookup::api_or_web_session::require_any_session_or_key::{require_any_session_or_key, AnySessionType};
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::http_server::web_utils::get_request_platform_type::get_request_platform_type;
use crate::state::server_state::ServerState;
use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::lookup_media_files_as_cdn_url_list_and_map;
/// Generate audio using the omni-gen unified endpoint.
/// Authenticates as either a web-session (cookie) user or an API-key (`Authorization` header) user.
/// Authenticates as a web-session (cookie) user, an API-key (`Authorization` header) user, or
/// an MCP-session (`Authorization` header) user.
#[utoipa::path(
post,
tag = "Omni Gen",
@@ -84,8 +85,8 @@ pub async fn omni_gen_audio_generate_handler(
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
// Either an API-key user (Authorization header) or a web-session (cookie) user.
let session = require_api_or_web_session(
// An API-key or MCP-session user (Authorization header) or a web-session (cookie) user.
let session = require_any_session_or_key(
&http_request,
&server_state.session_checker,
&server_state.avt_cookie_manager,
@@ -94,7 +95,7 @@ pub async fn omni_gen_audio_generate_handler(
let user_token = &session.user_token;
// AVT tokens are web-session only; API-key sessions never carry one.
// AVT tokens are web-session only; API-key and MCP sessions never carry one.
let maybe_avt_token = session.maybe_avt_token.clone();
// ==================== IDEMPOTENCY ==================== //
@@ -222,8 +223,9 @@ server_state.maybe_media_cdn_override_url.as_deref(),
// ==================== WRITE RESULT ==================== //
let maybe_platform_type = match session.session_type {
SessionType::Api => Some(PlatformType::ApiKey),
SessionType::WebSession => get_request_platform_type(&http_request),
AnySessionType::Api => Some(PlatformType::ApiKey),
AnySessionType::McpSession => Some(PlatformType::Mcp),
AnySessionType::WebSession => get_request_platform_type(&http_request),
};
let mut transaction = mysql_connection.begin().await.map_err(|err| {
@@ -37,7 +37,7 @@ use crate::http_server::endpoints::omni_gen::generate::image::insert_db_job::ins
use crate::http_server::endpoints::omni_gen::generate::image::insert_db_job::shared_job_args::SharedJobArgs;
use crate::http_server::endpoints::omni_gen::generate::image::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
use crate::http_server::endpoints::omni_gen::shared_utils::kinovi_account::KinoviAccount;
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::{require_api_or_web_session, SessionType};
use crate::http_server::user_lookup::api_or_web_session::require_any_session_or_key::{require_any_session_or_key, AnySessionType};
use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::http_server::web_utils::get_request_platform_type::get_request_platform_type;
@@ -45,7 +45,8 @@ use crate::state::server_state::ServerState;
use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::lookup_media_files_as_cdn_url_list_and_map;
/// Generate an image using the omni-gen unified endpoint.
/// Authenticates as either a web-session (cookie) user or an API-key (`Authorization` header) user.
/// Authenticates as a web-session (cookie) user, an API-key (`Authorization` header) user, or
/// an MCP-session (`Authorization` header) user.
#[utoipa::path(
post,
tag = "Omni Gen",
@@ -79,8 +80,8 @@ pub async fn omni_gen_image_generate_handler(
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
// Either an API-key user (Authorization header) or a web-session (cookie) user.
let session = require_api_or_web_session(
// An API-key or MCP-session user (Authorization header) or a web-session (cookie) user.
let session = require_any_session_or_key(
&http_request,
&server_state.session_checker,
&server_state.avt_cookie_manager,
@@ -89,7 +90,7 @@ pub async fn omni_gen_image_generate_handler(
let user_token = &session.user_token;
// AVT tokens are web-session only; API-key sessions never carry one.
// AVT tokens are web-session only; API-key and MCP sessions never carry one.
let maybe_avt_token = session.maybe_avt_token.clone();
// ==================== MODEL ACCESS CHECK ==================== //
@@ -213,8 +214,9 @@ server_state.maybe_media_cdn_override_url.as_deref(),
// ==================== WRITE RESULT ==================== //
let maybe_platform_type = match session.session_type {
SessionType::Api => Some(PlatformType::ApiKey),
SessionType::WebSession => get_request_platform_type(&http_request),
AnySessionType::Api => Some(PlatformType::ApiKey),
AnySessionType::McpSession => Some(PlatformType::Mcp),
AnySessionType::WebSession => get_request_platform_type(&http_request),
};
let mut transaction = mysql_connection
@@ -36,14 +36,15 @@ use crate::http_server::endpoints::omni_gen::generate::mesh::insert_db_job::inse
use crate::http_server::endpoints::omni_gen::generate::mesh::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs;
use crate::http_server::endpoints::omni_gen::shared_utils::mesh::validate_mesh_request::validate_mesh_request;
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::{require_api_or_web_session, SessionType};
use crate::http_server::user_lookup::api_or_web_session::require_any_session_or_key::{require_any_session_or_key, AnySessionType};
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::http_server::web_utils::get_request_platform_type::get_request_platform_type;
use crate::state::server_state::ServerState;
use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::lookup_media_files_as_cdn_url_list_and_map;
/// Generate a mesh (3D object) using the omni-gen unified endpoint.
/// Authenticates as either a web-session (cookie) user or an API-key (`Authorization` header) user.
/// Authenticates as a web-session (cookie) user, an API-key (`Authorization` header) user, or
/// an MCP-session (`Authorization` header) user.
#[utoipa::path(
post,
tag = "Omni Gen",
@@ -81,8 +82,8 @@ pub async fn omni_gen_mesh_generate_handler(
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
// Either an API-key user (Authorization header) or a web-session (cookie) user.
let session = require_api_or_web_session(
// An API-key or MCP-session user (Authorization header) or a web-session (cookie) user.
let session = require_any_session_or_key(
&http_request,
&server_state.session_checker,
&server_state.avt_cookie_manager,
@@ -91,7 +92,7 @@ pub async fn omni_gen_mesh_generate_handler(
let user_token = &session.user_token;
// AVT tokens are web-session only; API-key sessions never carry one.
// AVT tokens are web-session only; API-key and MCP sessions never carry one.
let maybe_avt_token = session.maybe_avt_token.clone();
// ==================== IDEMPOTENCY ==================== //
@@ -209,8 +210,9 @@ server_state.maybe_media_cdn_override_url.as_deref(),
// ==================== WRITE RESULT ==================== //
let maybe_platform_type = match session.session_type {
SessionType::Api => Some(PlatformType::ApiKey),
SessionType::WebSession => get_request_platform_type(&http_request),
AnySessionType::Api => Some(PlatformType::ApiKey),
AnySessionType::McpSession => Some(PlatformType::Mcp),
AnySessionType::WebSession => get_request_platform_type(&http_request),
};
let mut transaction = mysql_connection.begin().await.map_err(|err| {
@@ -37,14 +37,15 @@ use crate::http_server::endpoints::omni_gen::generate::splat::insert_db_job::ins
use crate::http_server::endpoints::omni_gen::generate::splat::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs;
use crate::http_server::endpoints::omni_gen::shared_utils::splat::validate_splat_request::validate_splat_request;
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::{require_api_or_web_session, SessionType};
use crate::http_server::user_lookup::api_or_web_session::require_any_session_or_key::{require_any_session_or_key, AnySessionType};
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::http_server::web_utils::get_request_platform_type::get_request_platform_type;
use crate::state::server_state::ServerState;
use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::lookup_media_files_as_cdn_url_list_and_map;
/// Generate a splat (3D world) using the omni-gen unified endpoint.
/// Authenticates as either a web-session (cookie) user or an API-key (`Authorization` header) user.
/// Authenticates as a web-session (cookie) user, an API-key (`Authorization` header) user, or
/// an MCP-session (`Authorization` header) user.
#[utoipa::path(
post,
tag = "Omni Gen",
@@ -82,8 +83,8 @@ pub async fn omni_gen_splat_generate_handler(
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
// Either an API-key user (Authorization header) or a web-session (cookie) user.
let session = require_api_or_web_session(
// An API-key or MCP-session user (Authorization header) or a web-session (cookie) user.
let session = require_any_session_or_key(
&http_request,
&server_state.session_checker,
&server_state.avt_cookie_manager,
@@ -92,7 +93,7 @@ pub async fn omni_gen_splat_generate_handler(
let user_token = &session.user_token;
// AVT tokens are web-session only; API-key sessions never carry one.
// AVT tokens are web-session only; API-key and MCP sessions never carry one.
let maybe_avt_token = session.maybe_avt_token.clone();
// ==================== IDEMPOTENCY ==================== //
@@ -214,8 +215,9 @@ server_state.maybe_media_cdn_override_url.as_deref(),
// ==================== WRITE RESULT ==================== //
let maybe_platform_type = match session.session_type {
SessionType::Api => Some(PlatformType::ApiKey),
SessionType::WebSession => get_request_platform_type(&http_request),
AnySessionType::Api => Some(PlatformType::ApiKey),
AnySessionType::McpSession => Some(PlatformType::Mcp),
AnySessionType::WebSession => get_request_platform_type(&http_request),
};
let mut transaction = mysql_connection.begin().await.map_err(|err| {
@@ -8,5 +8,6 @@ pub mod insert_db_job;
// Database fixture tests. Always compiled with `cfg(test)` so IDEs discover
// them and they can't bit-rot, but each test is `ignore`d unless the
// `database_tests` feature is enabled (see tests/mod.rs).
// `pub(crate)` so other modules' database tests can reuse `support::TestHarness`.
#[cfg(test)]
mod tests;
pub(crate) mod tests;
@@ -14,12 +14,13 @@ use crate::http_server::endpoints::omni_gen::generate::video::shared_video_gener
run_authenticated_video_generation, VideoGenerationAuth,
};
use crate::http_server::endpoints::omni_gen::shared_utils::video::validate_video_request::validate_video_request;
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::{require_api_or_web_session, SessionType};
use crate::http_server::user_lookup::api_or_web_session::require_any_session_or_key::{require_any_session_or_key, AnySessionType};
use crate::http_server::web_utils::get_request_platform_type::get_request_platform_type;
use crate::state::server_state::ServerState;
/// Generate a video using the omni-gen unified endpoint.
/// Authenticates as either a web-session (cookie) user or an API-key (`Authorization` header) user.
/// Authenticates as a web-session (cookie) user, an API-key (`Authorization` header) user, or
/// an MCP-session (`Authorization` header) user.
///
/// Razor-thin handler: authenticate, then delegate to the shared
/// generation core in `shared_video_generation` (which is authoritative for
@@ -55,8 +56,8 @@ pub async fn omni_gen_video_generate_handler(
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
// Either an API-key user (Authorization header) or a web-session (cookie) user.
let session = require_api_or_web_session(
// An API-key or MCP-session user (Authorization header) or a web-session (cookie) user.
let session = require_any_session_or_key(
&http_request,
&server_state.session_checker,
&server_state.avt_cookie_manager,
@@ -64,13 +65,14 @@ pub async fn omni_gen_video_generate_handler(
).await?;
let maybe_platform_type = match session.session_type {
SessionType::Api => Some(PlatformType::ApiKey),
SessionType::WebSession => get_request_platform_type(&http_request),
AnySessionType::Api => Some(PlatformType::ApiKey),
AnySessionType::McpSession => Some(PlatformType::Mcp),
AnySessionType::WebSession => get_request_platform_type(&http_request),
};
let auth = VideoGenerationAuth {
user_token: &session.user_token,
// AVT tokens are web-session only; API-key sessions never carry one.
// AVT tokens are web-session only; API-key and MCP sessions never carry one.
maybe_avt_token: session.maybe_avt_token.clone(),
maybe_platform_type,
};
@@ -0,0 +1,83 @@
//! MCP-session authentication tests for the omni_gen video generate endpoint.
//!
//! The endpoint accepts three credentials: web-session cookies, API keys, and
//! MCP session credentials (`mcp_session_…` in the Authorization header).
//! These tests pin the MCP path: a live session generates and bills exactly
//! like the other paths, and a revoked session is rejected before any
//! billable work.
use std::marker::PhantomData;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use mysql_queries::queries::mcp_sessions::revoke_mcp_session::{
revoke_mcp_session, RevokeMcpSessionArgs,
};
use crate::http_server::endpoints::omni_gen::generate::video::tests::support::{
base_generate_request, TestHarness, STARTING_CREDITS,
};
mod mcp_session_auth {
use super::*;
/// Base Seedance 2.0 at 720p 5s via an MCP session credential: the same 93
/// credits the parity tests pin for the cookie and API-key paths.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn mcp_session_generates_and_bills_exactly() {
const EXPECTED_CREDITS: u64 = 93;
let harness = TestHarness::create().await;
let user = harness.create_funded_user(STARTING_CREDITS).await;
let credential = harness.create_mcp_session(&user).await;
let mut request = base_generate_request(CommonVideoModel::Seedance2p0);
request.resolution = Some(CommonResolution::SevenTwentyP);
request.duration_seconds = Some(5);
let response = harness
.post_generate_via_mcp_session(&credential, request)
.await
.expect("MCP-session generation should succeed");
assert!(response.success);
assert_eq!(
STARTING_CREDITS - harness.wallet_balance(&user).await,
EXPECTED_CREDITS,
"MCP-session generation billed the wrong amount",
);
}
/// A revoked session must be rejected up front — no generation, no charge.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn revoked_mcp_session_is_rejected_and_charges_nothing() {
let harness = TestHarness::create().await;
let user = harness.create_funded_user(STARTING_CREDITS).await;
let credential = harness.create_mcp_session(&user).await;
let mut connection = harness.pool.acquire().await.expect("acquire for revoke");
let revoked = revoke_mcp_session(RevokeMcpSessionArgs {
private_session_token: &credential,
mysql_executor: &mut *connection,
phantom: PhantomData,
})
.await
.expect("revoke mcp session");
assert_eq!(revoked, 1);
let mut request = base_generate_request(CommonVideoModel::Seedance2p0);
request.resolution = Some(CommonResolution::SevenTwentyP);
request.duration_seconds = Some(5);
let result = harness.post_generate_via_mcp_session(&credential, request).await;
assert!(result.is_err(), "revoked MCP session must be rejected");
assert_eq!(
harness.wallet_balance(&user).await,
STARTING_CREDITS,
"rejected MCP-session generation must not charge",
);
}
}
@@ -25,6 +25,7 @@
pub mod support;
mod mcp_session_tests;
mod omni_api_parity_tests;
mod seedance_2p0;
mod seedance_2p5;
@@ -41,6 +41,7 @@ use mysql_queries::mediators::badge_granter::BadgeGranter;
use mysql_queries::queries::api_keys::insert_api_key::{insert_api_key, InsertApiKeyArgs};
use mysql_queries::mediators::firehose_publisher::FirehosePublisher;
use mysql_testing::fixtures::users::TestUser;
use tokens::tokens::mcp_session_private::McpSessionPrivateToken;
use redis_caching::redis_ttl_cache::RedisTtlCache;
use server_environment::ServerEnvironment;
use url_config::third_party_url_redirector::ThirdPartyUrlRedirector;
@@ -174,6 +175,36 @@ impl TestHarness {
api_key
}
/// Mint a live MCP session for `user` and return its private credential.
pub async fn create_mcp_session(&self, user: &TestUser) -> McpSessionPrivateToken {
mysql_testing::fixtures::mcp_sessions::create_test_mcp_session(&self.pool, &user.user_token)
.await
.expect("create mcp session")
.private_session_token
}
/// POST the request through the omni_gen generate handler, authenticating
/// with an MCP session credential in the Authorization header.
pub async fn post_generate_via_mcp_session(
&self,
private_session_token: &McpSessionPrivateToken,
request: OmniGenVideoCostAndGenerateRequest,
) -> Result<OmniGenVideoGenerateResponse, CommonWebError> {
let http_request = TestRequest::post()
.uri("/v1/omni_gen/generate/video")
.insert_header(("Authorization", format!("Bearer {}", private_session_token.as_str())))
.peer_addr("127.0.0.1:9999".parse().expect("peer addr"))
.to_http_request();
omni_gen_video_generate_handler(
http_request,
Json(request),
Data::new(self.server_state.clone()),
)
.await
.map(Json::into_inner)
}
/// POST the request through the omni_api (API-key only) generate handler,
/// authenticating with `api_key` in the Authorization header. Token-based
/// fields are mapped onto the omni_api request shape; URL fields stay None.
@@ -10,6 +10,7 @@ use crate::http_server::routes::application_routes::folders_routes::add_folders_
use crate::http_server::routes::application_routes::generate_routes::add_generate_routes;
use crate::http_server::routes::application_routes::internal_routes::add_internal_routes;
use crate::http_server::routes::application_routes::job_routes::add_job_routes;
use crate::http_server::routes::application_routes::mcp_session_routes::add_mcp_session_routes;
use crate::http_server::routes::application_routes::media_files_routes::add_media_file_routes;
use crate::http_server::routes::application_routes::moderation_routes::add_moderator_routes;
use crate::http_server::routes::application_routes::omni_api_routes::add_omni_api_routes;
@@ -64,6 +65,7 @@ where
app = add_folders_routes(app); // /v1/folders/...
app = add_tag_routes(app); // /v1/tags/...
app = add_api_keys_routes(app); // /v1/api_keys/...
app = add_mcp_session_routes(app); // /v1/mcp/session/...
app = add_featured_item_routes(app); // /v1/featured_item/...
app = add_prompts_routes(app); // /v1/prompts/...
@@ -0,0 +1,54 @@
use actix_http::body::MessageBody;
use actix_service::ServiceFactory;
use actix_web::dev::{ServiceRequest, ServiceResponse};
use actix_web::{web, App, Error, HttpResponse};
use crate::http_server::endpoints::mcp_sessions::create_mcp_session_handler::create_mcp_session_handler;
use crate::http_server::endpoints::mcp_sessions::delete_mcp_session_handler::delete_mcp_session_handler;
use crate::http_server::endpoints::mcp_sessions::list_mcp_sessions_handler::list_mcp_sessions_handler;
use crate::http_server::endpoints::mcp_sessions::refresh_mcp_session_handler::refresh_mcp_session_handler;
use crate::http_server::endpoints::mcp_sessions::revoke_mcp_session_handler::revoke_mcp_session_handler;
pub fn add_mcp_session_routes<T, B>(app: App<T>) -> App<T>
where
B: MessageBody,
T: ServiceFactory<
ServiceRequest,
Config = (),
Response = ServiceResponse<B>,
Error = Error,
InitError = (),
>,
{
app.service(
web::scope("/v1/mcp/session")
// NB: Static routes are registered BEFORE the dynamic `/{token}/delete`
// route so they take precedence. Session tokens are always `mcp_…`, so
// they can never collide with these literals anyway.
.service(
web::resource("/create")
.route(web::post().to(create_mcp_session_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
.service(
web::resource("/refresh")
.route(web::post().to(refresh_mcp_session_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
.service(
web::resource("/revoke")
.route(web::post().to(revoke_mcp_session_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
.service(
web::resource("/list")
.route(web::get().to(list_mcp_sessions_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
.service(
web::resource("/{token}/delete")
.route(web::post().to(delete_mcp_session_handler))
.route(web::head().to(|| HttpResponse::Ok())),
),
)
}
@@ -10,6 +10,7 @@ mod folders_routes;
mod generate_routes;
mod internal_routes;
mod job_routes;
mod mcp_session_routes;
mod media_files_routes;
mod moderation_routes;
mod omni_api_routes;
@@ -1 +1,2 @@
pub mod require_any_session_or_key;
pub mod require_api_or_web_session;
@@ -0,0 +1,502 @@
use std::marker::PhantomData;
use actix_web::http::header;
use actix_web::HttpRequest;
use log::warn;
use sqlx::{Executor, MySql};
use actix_artcraft::sessions::anonymous_visitor_tracking::avt_cookie_manager::AvtCookieManager;
use mysql_queries::queries::users::api_or_web_sessions::api_or_web_session_user_record::ApiOrWebSessionUserRecord;
use mysql_queries::queries::users::api_or_web_sessions::get_api_or_web_session_user_by_api_key::{
get_api_or_web_session_user_by_api_key, GetApiOrWebSessionUserByApiKeyArgs,
};
use mysql_queries::queries::users::api_or_web_sessions::get_api_or_web_session_user_by_mcp_private_session_token::{
get_api_or_web_session_user_by_mcp_private_session_token,
GetApiOrWebSessionUserByMcpPrivateSessionTokenArgs,
};
use mysql_queries::queries::users::api_or_web_sessions::get_api_or_web_session_user_by_session_token::{
get_api_or_web_session_user_by_session_token, GetApiOrWebSessionUserBySessionTokenArgs,
};
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::api_keys::ApiKeyToken;
use tokens::tokens::mcp_session_private::McpSessionPrivateToken;
use tokens::tokens::mcp_sessions::McpSessionToken;
use tokens::tokens::users::UserToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::user_lookup::user_session::session_utils::session_checker::SessionChecker;
use crate::http_server::web_utils::get_authorization_header_api_key::get_authorization_header_api_key;
const ADMIN_ROLE_SLUG: &str = "admin";
/// The authenticated identity behind a request that may come from an API client (`Authorization`
/// header API key), an MCP client (`Authorization` header MCP session credential), or the
/// website (session cookie). Fields common to all auth paths are populated uniformly, so
/// handlers don't need to care which path authenticated the user.
pub struct AnySession {
pub session_type: AnySessionType,
pub user_token: UserToken,
pub username: String,
pub display_name: String,
pub email_address: String,
pub user_role_slug: String,
pub can_ban_users: bool,
/// Optional comma-separated list of parseable `UserFeatureFlag` enum features.
pub maybe_feature_flags: Option<String>,
/// Only present for API-key sessions.
pub maybe_api_key_token: Option<ApiKeyToken>,
/// Only present for MCP sessions. This is the session's non-private management `token`, never
/// the `private_session_token`.
pub maybe_mcp_session_token: Option<McpSessionToken>,
/// Anonymous visitor tracking cookie. Only ever present for web sessions.
pub maybe_avt_token: Option<AnonymousVisitorTrackingToken>,
}
/// Which auth path authenticated the request.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum AnySessionType {
Api,
WebSession,
McpSession,
}
impl AnySession {
pub fn is_mod(&self) -> bool {
self.can_ban_users || self.user_role_slug == ADMIN_ROLE_SLUG
}
}
/// Authenticate a request as an API-key user, an MCP-session user, or a web-session (cookie)
/// user.
///
/// If an `Authorization` header is present, ONLY the header credential is attempted — a
/// malformed or unknown credential is a 401, never a fallthrough to cookies. Header credentials
/// with the `mcp_session_` prefix are looked up as MCP sessions (live and unexpired only);
/// anything else in the header is looked up as an API key. Without the header, the session
/// cookie is decoded and looked up, and the AVT (anonymous visitor tracking) cookie is captured
/// alongside it.
///
/// No path is cached: every request performs a fresh MySQL lookup. `mysql_executor` can be any
/// sqlx executor — prefer passing an already-open connection (`&mut *connection`) so the lookup
/// reuses it rather than acquiring a fresh one from the pool.
pub async fn require_any_session_or_key<'c, E>(
http_request: &HttpRequest,
session_checker: &SessionChecker,
avt_cookie_manager: &AvtCookieManager,
mysql_executor: E,
) -> Result<AnySession, CommonWebError>
where E: 'c + Executor<'c, Database = MySql>
{
if http_request.headers().contains_key(header::AUTHORIZATION) {
authenticate_by_header_credential(http_request, mysql_executor).await
} else {
authenticate_by_web_session(http_request, session_checker, avt_cookie_manager, mysql_executor).await
}
}
async fn authenticate_by_header_credential<'c, E>(
http_request: &HttpRequest,
mysql_executor: E,
) -> Result<AnySession, CommonWebError>
where E: 'c + Executor<'c, Database = MySql>
{
let credential = match get_authorization_header_api_key(http_request) {
Some(credential) => credential,
None => {
warn!("Authorization header present but not a usable credential");
return Err(CommonWebError::NotAuthorized);
}
};
// MCP session credentials live in the same header (and forms) as API keys; the token prefix
// is what tells them apart. (If MCP credentials ever become JWTs, add heuristics here.)
if credential.as_str_be_careful().starts_with(McpSessionPrivateToken::token_prefix()) {
let private_session_token = McpSessionPrivateToken::new_from_str(credential.as_str_be_careful());
return authenticate_by_mcp_session(&private_session_token, mysql_executor).await;
}
let maybe_record = get_api_or_web_session_user_by_api_key(GetApiOrWebSessionUserByApiKeyArgs {
api_key: &credential,
mysql_executor,
phantom: PhantomData,
})
.await
.map_err(|err| {
warn!("API key user lookup error: {:?}", err);
CommonWebError::from_error(err)
})?;
// A missing or soft-deleted key (or a key whose owner no longer exists) is a 401, not a leak
// of which case occurred.
let record = match maybe_record {
Some(record) => record,
None => {
warn!("No live API key user for presented key: {:?}", credential);
return Err(CommonWebError::NotAuthorized);
}
};
into_session_rejecting_banned(record, AnySessionType::Api, None)
}
async fn authenticate_by_mcp_session<'c, E>(
private_session_token: &McpSessionPrivateToken,
mysql_executor: E,
) -> Result<AnySession, CommonWebError>
where E: 'c + Executor<'c, Database = MySql>
{
let maybe_record = get_api_or_web_session_user_by_mcp_private_session_token(
GetApiOrWebSessionUserByMcpPrivateSessionTokenArgs {
private_session_token,
mysql_executor,
phantom: PhantomData,
})
.await
.map_err(|err| {
warn!("MCP session user lookup error: {:?}", err);
CommonWebError::from_error(err)
})?;
// A missing, terminated, or expired session (or one whose owner no longer exists) is a 401,
// not a leak of which case occurred.
let record = match maybe_record {
Some(record) => record,
None => {
warn!("No live MCP session for presented credential");
return Err(CommonWebError::NotAuthorized);
}
};
into_session_rejecting_banned(record, AnySessionType::McpSession, None)
}
async fn authenticate_by_web_session<'c, E>(
http_request: &HttpRequest,
session_checker: &SessionChecker,
avt_cookie_manager: &AvtCookieManager,
mysql_executor: E,
) -> Result<AnySession, CommonWebError>
where E: 'c + Executor<'c, Database = MySql>
{
let maybe_session_token = session_checker
.get_session_token(http_request)
.map_err(|err| {
warn!("Session cookie decode error: {:?}", err);
CommonWebError::from_error(err)
})?;
let session_token = match maybe_session_token {
Some(session_token) => session_token,
None => {
warn!("not logged in");
return Err(CommonWebError::NotAuthorized);
}
};
let maybe_record = get_api_or_web_session_user_by_session_token(GetApiOrWebSessionUserBySessionTokenArgs {
session_token: &session_token,
mysql_executor,
phantom: PhantomData,
})
.await
.map_err(|err| {
warn!("Web session user lookup error: {:?}", err);
CommonWebError::from_error(err)
})?;
let record = match maybe_record {
Some(record) => record,
None => {
warn!("Valid cookie; invalid session: {}", session_token);
return Err(CommonWebError::NotAuthorized);
}
};
let maybe_avt_token = avt_cookie_manager.get_avt_token_from_request(http_request);
into_session_rejecting_banned(record, AnySessionType::WebSession, maybe_avt_token)
}
fn into_session_rejecting_banned(
record: ApiOrWebSessionUserRecord,
session_type: AnySessionType,
maybe_avt_token: Option<AnonymousVisitorTrackingToken>,
) -> Result<AnySession, CommonWebError> {
if record.is_banned {
warn!("user is banned: {:?}", record.user_token.as_str());
return Err(CommonWebError::NotAuthorized);
}
Ok(AnySession {
session_type,
user_token: record.user_token,
username: record.username,
display_name: record.display_name,
email_address: record.email_address,
user_role_slug: record.user_role_slug,
can_ban_users: record.can_ban_users,
maybe_feature_flags: record.maybe_feature_flags,
maybe_api_key_token: record.maybe_api_key_token,
maybe_mcp_session_token: record.maybe_mcp_session_token,
maybe_avt_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 lookup 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
//! ```
//!
//! They run in PARALLEL: every test creates its own users, API keys, and MCP sessions.
use actix_web::cookie::Cookie;
use actix_web::test::TestRequest;
use actix_artcraft::sessions::user_sessions::http_user_session_manager::HttpUserSessionManager;
use artcraft_api_keys::ArtcraftApiKey;
use mysql_queries::queries::api_keys::insert_api_key::{insert_api_key, InsertApiKeyArgs};
use mysql_testing::fixtures::mcp_sessions::{create_test_mcp_session, force_expire_mcp_session};
use mysql_testing::fixtures::users::{create_test_user, TestUser};
use mysql_queries::queries::mcp_sessions::revoke_mcp_session::{
revoke_mcp_session, RevokeMcpSessionArgs,
};
use sqlx::MySqlPool;
use super::*;
const TEST_COOKIE_DOMAIN: &str = "localhost";
const TEST_COOKIE_SECRET: &str = "test_cookie_secret";
mod web_session_lookups {
use super::*;
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn web_session_cookie_authenticates() {
let harness = LookupHarness::create().await;
let user = harness.create_user().await;
let request = harness.request_with_session_cookie(&user);
let session = harness.require(&request).await.expect("web session should authenticate");
assert_eq!(session.session_type, AnySessionType::WebSession);
assert_eq!(session.user_token, user.user_token);
assert_eq!(session.username, user.username);
assert_eq!(session.email_address, user.email_address);
assert!(session.maybe_api_key_token.is_none());
assert!(session.maybe_mcp_session_token.is_none());
}
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn no_credentials_at_all_is_rejected() {
let harness = LookupHarness::create().await;
let request = TestRequest::default().to_http_request();
let result = harness.require(&request).await;
assert!(matches!(result, Err(CommonWebError::NotAuthorized)));
}
}
mod api_key_lookups {
use super::*;
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn api_key_authenticates() {
let harness = LookupHarness::create().await;
let user = harness.create_user().await;
let api_key = harness.create_api_key(&user).await;
let request = request_with_bearer(api_key.as_str_be_careful());
let session = harness.require(&request).await.expect("API key should authenticate");
assert_eq!(session.session_type, AnySessionType::Api);
assert_eq!(session.user_token, user.user_token);
assert!(session.maybe_api_key_token.is_some());
assert!(session.maybe_mcp_session_token.is_none());
assert!(session.maybe_avt_token.is_none());
}
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn unknown_api_key_is_rejected() {
let harness = LookupHarness::create().await;
let request = request_with_bearer("artcraft_api_no_such_key_0000000000000000");
let result = harness.require(&request).await;
assert!(matches!(result, Err(CommonWebError::NotAuthorized)));
}
}
mod mcp_session_lookups {
use super::*;
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn mcp_session_authenticates() {
let harness = LookupHarness::create().await;
let user = harness.create_user().await;
let mcp_session = create_test_mcp_session(&harness.pool, &user.user_token)
.await
.expect("create mcp session");
let request = request_with_bearer(mcp_session.private_session_token.as_str());
let session = harness.require(&request).await.expect("MCP session should authenticate");
assert_eq!(session.session_type, AnySessionType::McpSession);
assert_eq!(session.user_token, user.user_token);
assert_eq!(session.maybe_mcp_session_token, Some(mcp_session.token));
assert!(session.maybe_api_key_token.is_none());
assert!(session.maybe_avt_token.is_none());
}
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn expired_mcp_session_is_rejected() {
let harness = LookupHarness::create().await;
let user = harness.create_user().await;
let mcp_session = create_test_mcp_session(&harness.pool, &user.user_token)
.await
.expect("create mcp session");
force_expire_mcp_session(&harness.pool, &mcp_session.token)
.await
.expect("expire mcp session");
let request = request_with_bearer(mcp_session.private_session_token.as_str());
let result = harness.require(&request).await;
assert!(matches!(result, Err(CommonWebError::NotAuthorized)));
}
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn revoked_mcp_session_is_rejected() {
let harness = LookupHarness::create().await;
let user = harness.create_user().await;
let mcp_session = create_test_mcp_session(&harness.pool, &user.user_token)
.await
.expect("create mcp session");
let mut connection = harness.pool.acquire().await.expect("acquire for revoke");
let revoked = revoke_mcp_session(RevokeMcpSessionArgs {
private_session_token: &mcp_session.private_session_token,
mysql_executor: &mut *connection,
phantom: PhantomData,
})
.await
.expect("revoke mcp session");
assert_eq!(revoked, 1);
let request = request_with_bearer(mcp_session.private_session_token.as_str());
let result = harness.require(&request).await;
assert!(matches!(result, Err(CommonWebError::NotAuthorized)));
}
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn unknown_mcp_credential_is_rejected() {
let harness = LookupHarness::create().await;
let request = request_with_bearer("mcp_session_no_such_session_00000000000000000000000000000000");
let result = harness.require(&request).await;
assert!(matches!(result, Err(CommonWebError::NotAuthorized)));
}
}
/// The guarded test pool plus the cookie machinery `require_any_session_or_key` needs.
struct LookupHarness {
pool: MySqlPool,
session_cookie_manager: HttpUserSessionManager,
session_checker: SessionChecker,
avt_cookie_manager: AvtCookieManager,
}
impl LookupHarness {
async fn create() -> LookupHarness {
let pool = mysql_testing::pool::create_test_pool().await;
let session_cookie_manager = HttpUserSessionManager::new(TEST_COOKIE_DOMAIN, TEST_COOKIE_SECRET)
.expect("session cookie manager");
let session_checker = SessionChecker::new(&session_cookie_manager);
let avt_cookie_manager =
AvtCookieManager::new(TEST_COOKIE_DOMAIN, TEST_COOKIE_SECRET).expect("avt cookie manager");
LookupHarness {
pool,
session_cookie_manager,
session_checker,
avt_cookie_manager,
}
}
async fn create_user(&self) -> TestUser {
create_test_user(&self.pool).await.expect("create test user")
}
/// Mint and insert an API key owned by `user`. The key value derives from the (unique)
/// user token so parallel tests never collide on the `api_key` column.
async fn create_api_key(&self, user: &TestUser) -> ArtcraftApiKey {
let entropy = user.user_token.as_str().trim_start_matches("user_").to_string();
let api_key = ArtcraftApiKey::new_from_str(&format!("artcraft_api_test{entropy}"));
let mut connection = self.pool.acquire().await.expect("acquire for api key");
insert_api_key(InsertApiKeyArgs {
owner_user_token: &user.user_token,
ip_address: "127.0.0.1",
name: "test key",
maybe_description: None,
api_key: &api_key,
mysql_executor: &mut *connection,
phantom: PhantomData,
})
.await
.expect("insert api key");
api_key
}
/// Run the lookup under test against a fresh connection from the test pool.
async fn require(&self, request: &HttpRequest) -> Result<AnySession, CommonWebError> {
let mut connection = self.pool.acquire().await.expect("acquire for lookup");
require_any_session_or_key(
request,
&self.session_checker,
&self.avt_cookie_manager,
&mut *connection,
)
.await
}
/// A dummy HTTP request carrying the user's session cookie and no Authorization header.
fn request_with_session_cookie(&self, user: &TestUser) -> HttpRequest {
let cookie = self
.session_cookie_manager
.create_cookie(&user.session_token, &user.user_token)
.expect("create session cookie");
// TestRequest wants an owned cookie with a 'static-compatible lifetime.
let cookie = Cookie::new("session", cookie.value().to_string());
TestRequest::default().cookie(cookie).to_http_request()
}
}
/// A dummy HTTP request presenting `credential` as a Bearer Authorization header.
fn request_with_bearer(credential: &str) -> HttpRequest {
TestRequest::default()
.insert_header(("Authorization", format!("Bearer {credential}")))
.to_http_request()
}
}
@@ -0,0 +1,74 @@
use actix_web::HttpRequest;
use tokens::tokens::mcp_session_private::McpSessionPrivateToken;
use crate::http_server::web_utils::get_authorization_header_api_key::get_authorization_header_api_key;
/// Extract an MCP session's private credential from a request's `Authorization` header.
///
/// MCP session credentials live in the same header (and the same `Bearer <credential>`,
/// `Key <credential>`, and bare `<credential>` forms) as API keys, so this reuses the API-key
/// header parsing and then requires the `mcp_session_` token prefix. Returns `None` if the
/// header is absent, unusable, or carries a credential that is not an MCP session token —
/// callers fall back to (or reject as) other credential types.
pub fn get_authorization_header_mcp_private_session_token(
http_request: &HttpRequest,
) -> Option<McpSessionPrivateToken> {
let credential = get_authorization_header_api_key(http_request)?;
if !credential.as_str_be_careful().starts_with(McpSessionPrivateToken::token_prefix()) {
return None;
}
Some(McpSessionPrivateToken::new_from_str(credential.as_str_be_careful()))
}
#[cfg(test)]
mod tests {
use actix_web::test::TestRequest;
use super::*;
const SAMPLE_MCP_CREDENTIAL: &str =
"mcp_session_55ax0zhd580m598r6n4n7szdwjb2b28sypapvawh3k9m2p4q6r8t";
const SAMPLE_API_KEY: &str = "artcraft_api_55ax0zhd580m598r6n4n7szdwjb2b28sypapvawh";
#[test]
fn bearer_scheme() {
assert_eq!(
parsed(&format!("Bearer {SAMPLE_MCP_CREDENTIAL}")),
Some(SAMPLE_MCP_CREDENTIAL.to_string()));
}
#[test]
fn key_scheme() {
assert_eq!(
parsed(&format!("Key {SAMPLE_MCP_CREDENTIAL}")),
Some(SAMPLE_MCP_CREDENTIAL.to_string()));
}
#[test]
fn bare_credential_aws_style() {
assert_eq!(parsed(SAMPLE_MCP_CREDENTIAL), Some(SAMPLE_MCP_CREDENTIAL.to_string()));
}
#[test]
fn api_key_is_not_an_mcp_credential() {
assert_eq!(parsed(&format!("Bearer {SAMPLE_API_KEY}")), None);
assert_eq!(parsed(SAMPLE_API_KEY), None);
}
#[test]
fn missing_header_returns_none() {
let http_request = TestRequest::default().to_http_request();
assert_eq!(get_authorization_header_mcp_private_session_token(&http_request), None);
}
fn parsed(header_value: &str) -> Option<String> {
let http_request = TestRequest::default()
.insert_header(("Authorization", header_value))
.to_http_request();
get_authorization_header_mcp_private_session_token(&http_request)
.map(|token| token.as_str().to_string())
}
}
@@ -1,6 +1,7 @@
pub mod bucket_urls;
pub mod filter_model_name;
pub mod get_authorization_header_api_key;
pub mod get_authorization_header_mcp_private_session_token;
pub mod get_host_header;
pub mod get_request_platform_type;
pub mod handle_multipart_error;