diff --git a/.sqlx/query-5ad1951163b15a9e241f949f2e66cde830d51d70b395f93000bc47ecb9c2083f.json b/.sqlx/query-5ad1951163b15a9e241f949f2e66cde830d51d70b395f93000bc47ecb9c2083f.json new file mode 100644 index 0000000000..28e862114b --- /dev/null +++ b/.sqlx/query-5ad1951163b15a9e241f949f2e66cde830d51d70b395f93000bc47ecb9c2083f.json @@ -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" +} diff --git a/.sqlx/query-827eb468e1f732f72e0dc8f655add2b50f12cb933bba230a1fd4abb594a1944a.json b/.sqlx/query-827eb468e1f732f72e0dc8f655add2b50f12cb933bba230a1fd4abb594a1944a.json new file mode 100644 index 0000000000..1fe35e8fc0 --- /dev/null +++ b/.sqlx/query-827eb468e1f732f72e0dc8f655add2b50f12cb933bba230a1fd4abb594a1944a.json @@ -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" +} diff --git a/.sqlx/query-847acba63c2ee29b5d59bea4cdd88f12ec4b3e8a4317ab26a1e9039c0b66a1b2.json b/.sqlx/query-847acba63c2ee29b5d59bea4cdd88f12ec4b3e8a4317ab26a1e9039c0b66a1b2.json new file mode 100644 index 0000000000..69bbb5d066 --- /dev/null +++ b/.sqlx/query-847acba63c2ee29b5d59bea4cdd88f12ec4b3e8a4317ab26a1e9039c0b66a1b2.json @@ -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" +} diff --git a/.sqlx/query-8b61abdfcfcd5c601489521793ef5c8cbe47f408e5c7e9c9a6bf01c9c8a57247.json b/.sqlx/query-8b61abdfcfcd5c601489521793ef5c8cbe47f408e5c7e9c9a6bf01c9c8a57247.json new file mode 100644 index 0000000000..eae26a39bd --- /dev/null +++ b/.sqlx/query-8b61abdfcfcd5c601489521793ef5c8cbe47f408e5c7e9c9a6bf01c9c8a57247.json @@ -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" +} diff --git a/.sqlx/query-c59cea297d441b4960074802809af5ff0bdf8969abb34c01101584bffd887010.json b/.sqlx/query-c59cea297d441b4960074802809af5ff0bdf8969abb34c01101584bffd887010.json new file mode 100644 index 0000000000..200da018d2 --- /dev/null +++ b/.sqlx/query-c59cea297d441b4960074802809af5ff0bdf8969abb34c01101584bffd887010.json @@ -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" +} diff --git a/.sqlx/query-ed8f7ef94099d2dddf50f819316485351e149db72ce8a4f5cff6b57f4b84244d.json b/.sqlx/query-ed8f7ef94099d2dddf50f819316485351e149db72ce8a4f5cff6b57f4b84244d.json new file mode 100644 index 0000000000..e57a3d52b2 --- /dev/null +++ b/.sqlx/query-ed8f7ef94099d2dddf50f819316485351e149db72ce8a4f5cff6b57f4b84244d.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "\nUPDATE mcp_sessions\nSET\n expires_at = NOW() + INTERVAL ? DAY,\n private_refresh_token = ?,\n ip_address_update = ?\nWHERE private_session_token = ?\n AND private_refresh_token = ?\n AND maybe_deleted_at IS NULL\n AND expires_at > NOW()\nLIMIT 1\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "ed8f7ef94099d2dddf50f819316485351e149db72ce8a4f5cff6b57f4b84244d" +} diff --git a/.sqlx/query-f67a73ea3d5751cc76d2d6f954bd939179b3e9005a359e04c3a4dd78f8fe562e.json b/.sqlx/query-f67a73ea3d5751cc76d2d6f954bd939179b3e9005a359e04c3a4dd78f8fe562e.json new file mode 100644 index 0000000000..6e5eb38e63 --- /dev/null +++ b/.sqlx/query-f67a73ea3d5751cc76d2d6f954bd939179b3e9005a359e04c3a4dd78f8fe562e.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "\nINSERT INTO mcp_sessions\nSET\n token = ?,\n private_session_token = ?,\n private_refresh_token = ?,\n user_token = ?,\n maybe_mcp_client_name = ?,\n maybe_mcp_client_version = ?,\n maybe_mcp_client_vendor = ?,\n ip_address_creation = ?,\n ip_address_update = ?,\n created_at = NOW(),\n expires_at = NOW() + INTERVAL ? DAY\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 10 + }, + "nullable": [] + }, + "hash": "f67a73ea3d5751cc76d2d6f954bd939179b3e9005a359e04c3a4dd78f8fe562e" +} diff --git a/_database/sql/migrations/2026-08-27-084409-0000_create_mcp_sessions/down.sql b/_database/sql/migrations/2026-08-27-084409-0000_create_mcp_sessions/down.sql new file mode 100644 index 0000000000..502f8c55cf --- /dev/null +++ b/_database/sql/migrations/2026-08-27-084409-0000_create_mcp_sessions/down.sql @@ -0,0 +1,5 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +DROP TABLE IF EXISTS mcp_sessions; diff --git a/_database/sql/migrations/2026-08-27-084409-0000_create_mcp_sessions/up.sql b/_database/sql/migrations/2026-08-27-084409-0000_create_mcp_sessions/up.sql new file mode 100644 index 0000000000..e5a42bc1ee --- /dev/null +++ b/_database/sql/migrations/2026-08-27-084409-0000_create_mcp_sessions/up.sql @@ -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; diff --git a/_database/sql/migrations_squashed/mcp_sessions.sql b/_database/sql/migrations_squashed/mcp_sessions.sql new file mode 100644 index 0000000000..e5a42bc1ee --- /dev/null +++ b/_database/sql/migrations_squashed/mcp_sessions.sql @@ -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; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs index 5bc68d38ae..1e284b44d9 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs @@ -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; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/common.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/common.rs new file mode 100644 index 0000000000..050633617f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/common.rs @@ -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, + + /// Trimmed version of the MCP client, if any. + pub maybe_mcp_client_version: Option, + + /// Trimmed vendor of the MCP client, if any. + pub maybe_mcp_client_vendor: Option, + + pub ip_address_creation: String, + pub ip_address_update: String, + + pub created_at: DateTime, + pub updated_at: DateTime, + + /// When the session expires, if not refreshed. + pub expires_at: DateTime, + + /// Termination timestamp. `None` for live (though possibly expired) sessions. + pub maybe_deleted_at: Option>, +} + +/// 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, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/create_mcp_session.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/create_mcp_session.rs new file mode 100644 index 0000000000..ae5b236f9c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/create_mcp_session.rs @@ -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, + + /// Version of the MCP client, if any. + pub maybe_mcp_client_version: Option, + + /// Vendor of the MCP client, if any. + pub maybe_mcp_client_vendor: Option, +} + +#[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, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/delete_mcp_session.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/delete_mcp_session.rs new file mode 100644 index 0000000000..03553c8562 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/delete_mcp_session.rs @@ -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, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/list_mcp_sessions.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/list_mcp_sessions.rs new file mode 100644 index 0000000000..341f0f9b82 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/list_mcp_sessions.rs @@ -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, + + /// Number of rows to skip. Defaults to 0. + pub offset: Option, +} + +#[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, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/mod.rs new file mode 100644 index 0000000000..10ba221d9e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/mod.rs @@ -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; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/refresh_mcp_session.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/refresh_mcp_session.rs new file mode 100644 index 0000000000..56d2582935 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/refresh_mcp_session.rs @@ -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, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/revoke_mcp_session.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/revoke_mcp_session.rs new file mode 100644 index 0000000000..bccd00d702 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/revoke_mcp_session.rs @@ -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, +} diff --git a/crates/schema/database/mysql_queries/src/queries/mcp_sessions/delete_mcp_session.rs b/crates/schema/database/mysql_queries/src/queries/mcp_sessions/delete_mcp_session.rs new file mode 100644 index 0000000000..8fd3b6a1de --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/mcp_sessions/delete_mcp_session.rs @@ -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 +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()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/mcp_sessions/get_mcp_session_by_token.rs b/crates/schema/database/mysql_queries/src/queries/mcp_sessions/get_mcp_session_by_token.rs new file mode 100644 index 0000000000..7f254c94be --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/mcp_sessions/get_mcp_session_by_token.rs @@ -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, 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, + })) +} diff --git a/crates/schema/database/mysql_queries/src/queries/mcp_sessions/insert_mcp_session.rs b/crates/schema/database/mysql_queries/src/queries/mcp_sessions/insert_mcp_session.rs new file mode 100644 index 0000000000..d47e8829c5 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/mcp_sessions/insert_mcp_session.rs @@ -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 +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, + }) +} diff --git a/crates/schema/database/mysql_queries/src/queries/mcp_sessions/list_mcp_sessions_for_user.rs b/crates/schema/database/mysql_queries/src/queries/mcp_sessions/list_mcp_sessions_for_user.rs new file mode 100644 index 0000000000..ad728851b0 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/mcp_sessions/list_mcp_sessions_for_user.rs @@ -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, 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::>(); + + Ok(rows) +} diff --git a/crates/schema/database/mysql_queries/src/queries/mcp_sessions/mcp_session_row.rs b/crates/schema/database/mysql_queries/src/queries/mcp_sessions/mcp_session_row.rs new file mode 100644 index 0000000000..991f7f5583 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/mcp_sessions/mcp_session_row.rs @@ -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, + pub maybe_mcp_client_version: Option, + pub maybe_mcp_client_vendor: Option, + + pub ip_address_creation: String, + pub ip_address_update: String, + + pub created_at: DateTime, + pub updated_at: DateTime, + + /// When the session expires, if not refreshed. + pub expires_at: DateTime, + + /// Soft-delete (termination) timestamp. `None` for live sessions. + pub maybe_deleted_at: Option>, +} diff --git a/crates/schema/database/mysql_queries/src/queries/mcp_sessions/mod.rs b/crates/schema/database/mysql_queries/src/queries/mcp_sessions/mod.rs new file mode 100644 index 0000000000..4948cb115a --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/mcp_sessions/mod.rs @@ -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; diff --git a/crates/schema/database/mysql_queries/src/queries/mcp_sessions/refresh_mcp_session.rs b/crates/schema/database/mysql_queries/src/queries/mcp_sessions/refresh_mcp_session.rs new file mode 100644 index 0000000000..b4ae41c65f --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/mcp_sessions/refresh_mcp_session.rs @@ -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, 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)) +} diff --git a/crates/schema/database/mysql_queries/src/queries/mcp_sessions/revoke_mcp_session.rs b/crates/schema/database/mysql_queries/src/queries/mcp_sessions/revoke_mcp_session.rs new file mode 100644 index 0000000000..221fbc9f8b --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/mcp_sessions/revoke_mcp_session.rs @@ -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 +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()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/mod.rs b/crates/schema/database/mysql_queries/src/queries/mod.rs index c093cb79fd..b9ccf4ee14 100644 --- a/crates/schema/database/mysql_queries/src/queries/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/mod.rs @@ -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; diff --git a/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/api_or_web_session_user_record.rs b/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/api_or_web_session_user_record.rs index 2984d29dde..dbc05b5e85 100644 --- a/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/api_or_web_session_user_record.rs +++ b/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/api_or_web_session_user_record.rs @@ -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, + + /// 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, } diff --git a/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/get_api_or_web_session_user_by_api_key.rs b/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/get_api_or_web_session_user_by_api_key.rs index c47c7b9d4d..0d0cfbc75c 100644 --- a/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/get_api_or_web_session_user_by_api_key.rs +++ b/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/get_api_or_web_session_user_by_api_key.rs @@ -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, })) } diff --git a/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/get_api_or_web_session_user_by_mcp_private_session_token.rs b/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/get_api_or_web_session_user_by_mcp_private_session_token.rs new file mode 100644 index 0000000000..5815053efc --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/get_api_or_web_session_user_by_mcp_private_session_token.rs @@ -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, 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), + })) +} diff --git a/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/get_api_or_web_session_user_by_session_token.rs b/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/get_api_or_web_session_user_by_session_token.rs index 77cf259fab..5f6d43c516 100644 --- a/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/get_api_or_web_session_user_by_session_token.rs +++ b/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/get_api_or_web_session_user_by_session_token.rs @@ -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, })) } diff --git a/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/mod.rs b/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/mod.rs index 6751f3c647..7a20da117b 100644 --- a/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/mod.rs @@ -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; diff --git a/crates/schema/database/mysql_testing/src/fixtures/mcp_sessions.rs b/crates/schema/database/mysql_testing/src/fixtures/mcp_sessions.rs new file mode 100644 index 0000000000..41cb069fbb --- /dev/null +++ b/crates/schema/database/mysql_testing/src/fixtures/mcp_sessions.rs @@ -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 { + 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(()) +} diff --git a/crates/schema/database/mysql_testing/src/fixtures/mod.rs b/crates/schema/database/mysql_testing/src/fixtures/mod.rs index 633d3cc9d4..7c89084677 100644 --- a/crates/schema/database/mysql_testing/src/fixtures/mod.rs +++ b/crates/schema/database/mysql_testing/src/fixtures/mod.rs @@ -1,3 +1,4 @@ +pub mod mcp_sessions; pub mod media_files; pub mod users; pub mod wallets; diff --git a/crates/schema/public/enums/src/common/platform_type.rs b/crates/schema/public/enums/src/common/platform_type.rs index 472e703062..a792c173f8 100644 --- a/crates/schema/public/enums/src/common/platform_type.rs +++ b/crates/schema/public/enums/src/common/platform_type.rs @@ -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)); diff --git a/crates/schema/public/tokens/src/prefixes.rs b/crates/schema/public/tokens/src/prefixes.rs index ca907a0e1a..e292922499 100644 --- a/crates/schema/public/tokens/src/prefixes.rs +++ b/crates/schema/public/tokens/src/prefixes.rs @@ -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. diff --git a/crates/schema/public/tokens/src/tokens/mcp_session_private.rs b/crates/schema/public/tokens/src/tokens/mcp_session_private.rs new file mode 100644 index 0000000000..cb5a316d1b --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/mcp_session_private.rs @@ -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); diff --git a/crates/schema/public/tokens/src/tokens/mcp_session_refresh.rs b/crates/schema/public/tokens/src/tokens/mcp_session_refresh.rs new file mode 100644 index 0000000000..9cffcdbf37 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/mcp_session_refresh.rs @@ -0,0 +1,24 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The rotating refresh credential for an `mcp_sessions` row (the `private_refresh_token` +/// column). +/// +/// An MCP client presents this on the session-refresh endpoint to extend its session; each +/// successful refresh ROTATES the value, invalidating the previous one. Like the session +/// credential, it is a secret with extra entropy — never list or display it after it is handed +/// out. The session's other credentials are `McpSessionToken` (management handle) and +/// `McpSessionPrivateToken` (the session secret). +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct McpSessionRefreshToken(pub String); + +impl_string_token!(McpSessionRefreshToken); +impl_mysql_token_from_row!(McpSessionRefreshToken); +impl_crockford_generator!(McpSessionRefreshToken, 64usize, TokenPrefix::McpSessionRefresh, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/mcp_sessions.rs b/crates/schema/public/tokens/src/tokens/mcp_sessions.rs new file mode 100644 index 0000000000..ff3a30a109 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/mcp_sessions.rs @@ -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); diff --git a/crates/schema/public/tokens/src/tokens/mod.rs b/crates/schema/public/tokens/src/tokens/mod.rs index 114629567d..30992bc518 100644 --- a/crates/schema/public/tokens/src/tokens/mod.rs +++ b/crates/schema/public/tokens/src/tokens/mod.rs @@ -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; diff --git a/crates/service/web/storyteller_web/src/docs/api_doc.rs b/crates/service/web/storyteller_web/src/docs/api_doc.rs index 63a9891177..5fbfaa3397 100644 --- a/crates/service/web/storyteller_web/src/docs/api_doc.rs +++ b/crates/service/web/storyteller_web/src/docs/api_doc.rs @@ -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, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/create_mcp_session_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/create_mcp_session_handler.rs new file mode 100644 index 0000000000..556335e74f --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/create_mcp_session_handler.rs @@ -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, + server_state: web::Data>, +) -> Result, 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, +) -> Result, 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())) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/delete_mcp_session_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/delete_mcp_session_handler.rs new file mode 100644 index 0000000000..f6d68f0ba1 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/delete_mcp_session_handler.rs @@ -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, + server_state: web::Data>, +) -> Result, 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 })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/list_mcp_sessions_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/list_mcp_sessions_handler.rs new file mode 100644 index 0000000000..bae9fa7334 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/list_mcp_sessions_handler.rs @@ -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, + server_state: web::Data>, +) -> Result, 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, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/mcp_session_info_conversion.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/mcp_session_info_conversion.rs new file mode 100644 index 0000000000..7e4532c82b --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/mcp_session_info_conversion.rs @@ -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, + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/mod.rs new file mode 100644 index 0000000000..0cee8be2bf --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/mod.rs @@ -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; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/refresh_mcp_session_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/refresh_mcp_session_handler.rs new file mode 100644 index 0000000000..71b55cf5b2 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/refresh_mcp_session_handler.rs @@ -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, + server_state: web::Data>, +) -> Result, 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 { + let http_request = TestRequest::post() + .uri("/v1/mcp/session/refresh") + .insert_header(( + "Authorization", + format!("Bearer {}", mcp_session.private_session_token.as_str()), + )) + .peer_addr("127.0.0.1:9999".parse().expect("peer addr")) + .to_http_request(); + + let request = RefreshMcpSessionRequest { + private_refresh_token: refresh_token.clone(), + }; + + refresh_mcp_session_handler( + http_request, + Json(request), + Data::new(harness.server_state.clone()), + ) + .await + .map(Json::into_inner) + } + + async fn session_expiry( + harness: &TestHarness, + mcp_session: &InsertedMcpSession, + ) -> chrono::DateTime { + use std::marker::PhantomData; + + use mysql_queries::queries::mcp_sessions::get_mcp_session_by_token::{ + get_mcp_session_by_token, GetMcpSessionByTokenArgs, + }; + + let mut connection = harness.pool.acquire().await.expect("acquire for expiry read"); + get_mcp_session_by_token(GetMcpSessionByTokenArgs { + token: &mcp_session.token, + mysql_executor: &mut *connection, + phantom: PhantomData, + }) + .await + .expect("read session") + .expect("session row exists") + .expires_at + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/revoke_mcp_session_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/revoke_mcp_session_handler.rs new file mode 100644 index 0000000000..71a4fdc1b7 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/mcp_sessions/revoke_mcp_session_handler.rs @@ -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>, +) -> Result, 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 })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/common_utils/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/common_utils/mod.rs index 5f771f495a..a49cb55d8b 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/common_utils/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/common_utils/mod.rs @@ -1 +1,2 @@ +pub mod read_upload_session; pub mod try_parse_generation_provider; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/common_utils/read_upload_session.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/common_utils/read_upload_session.rs new file mode 100644 index 0000000000..e66b939ce7 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/common_utils/read_upload_session.rs @@ -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, + + /// Present when the request carried a valid web-session cookie (and no `Authorization` + /// header). + pub maybe_cookie_session: Option, +} + +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 { + 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, +) -> Result { + 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 { + 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()) + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_audio_media_file_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_audio_media_file_handler.rs index cca9eb7e64..7c730ed111 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_audio_media_file_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_audio_media_file_handler.rs @@ -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 ==================== // diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_generic/process_upload_media_file.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_generic/process_upload_media_file.rs index e830d447df..480350b13a 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_generic/process_upload_media_file.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_generic/process_upload_media_file.rs @@ -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() diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_image_media_file_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_image_media_file_handler.rs index 6627d65a37..312aa0d652 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_image_media_file_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_image_media_file_handler.rs @@ -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 ==================== // diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_spz_media_file_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_spz_media_file_handler.rs index 335708208b..edc3c6c476 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_spz_media_file_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_spz_media_file_handler.rs @@ -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 ==================== // diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_video_new/upload_new_video_media_file_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_video_new/upload_new_video_media_file_handler.rs index 20b4ec6581..cb503a4507 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_video_new/upload_new_video_media_file_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_video_new/upload_new_video_media_file_handler.rs @@ -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 ==================== // diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs index a3fd64ddac..d714cea3f7 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs @@ -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; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/audio/omni_gen_audio_generate_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/audio/omni_gen_audio_generate_handler.rs index 474875d2ac..49480cf719 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/audio/omni_gen_audio_generate_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/audio/omni_gen_audio_generate_handler.rs @@ -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| { diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/omni_gen_image_generate_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/omni_gen_image_generate_handler.rs index e5b464f4fd..0d60732628 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/omni_gen_image_generate_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/omni_gen_image_generate_handler.rs @@ -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 diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/omni_gen_mesh_generate_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/omni_gen_mesh_generate_handler.rs index 5c24801b7c..ebbc9bc239 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/omni_gen_mesh_generate_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/omni_gen_mesh_generate_handler.rs @@ -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| { diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/omni_gen_splat_generate_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/omni_gen_splat_generate_handler.rs index fd8d613b40..f7acee2284 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/omni_gen_splat_generate_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/omni_gen_splat_generate_handler.rs @@ -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| { diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/mod.rs index 0f1fd21489..6e8e6b765b 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/mod.rs @@ -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; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/omni_gen_video_generate_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/omni_gen_video_generate_handler.rs index aa86e352e9..26fbdfab02 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/omni_gen_video_generate_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/omni_gen_video_generate_handler.rs @@ -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, }; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/mcp_session_tests.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/mcp_session_tests.rs new file mode 100644 index 0000000000..4ba0e7ca01 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/mcp_session_tests.rs @@ -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", + ); + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/mod.rs index db2c4504d0..cc6f902615 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/mod.rs @@ -25,6 +25,7 @@ pub mod support; +mod mcp_session_tests; mod omni_api_parity_tests; mod seedance_2p0; mod seedance_2p5; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/support.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/support.rs index 3fbf0f86ff..beecd576ce 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/support.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/support.rs @@ -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 { + 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. diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs index 8d7d67efbd..18362d2cfe 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs @@ -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/... diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mcp_session_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mcp_session_routes.rs new file mode 100644 index 0000000000..202f83136a --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mcp_session_routes.rs @@ -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(app: App) -> App +where + B: MessageBody, + T: ServiceFactory< + ServiceRequest, + Config = (), + Response = ServiceResponse, + 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())), + ), + ) +} diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs index c31e9121c0..a61146eca7 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs @@ -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; diff --git a/crates/service/web/storyteller_web/src/http_server/user_lookup/api_or_web_session/mod.rs b/crates/service/web/storyteller_web/src/http_server/user_lookup/api_or_web_session/mod.rs index 0dfb97943d..a591538e27 100644 --- a/crates/service/web/storyteller_web/src/http_server/user_lookup/api_or_web_session/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/user_lookup/api_or_web_session/mod.rs @@ -1 +1,2 @@ +pub mod require_any_session_or_key; pub mod require_api_or_web_session; diff --git a/crates/service/web/storyteller_web/src/http_server/user_lookup/api_or_web_session/require_any_session_or_key.rs b/crates/service/web/storyteller_web/src/http_server/user_lookup/api_or_web_session/require_any_session_or_key.rs new file mode 100644 index 0000000000..64bcd3c5eb --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/user_lookup/api_or_web_session/require_any_session_or_key.rs @@ -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, + + /// Only present for API-key sessions. + pub maybe_api_key_token: Option, + + /// 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, + + /// Anonymous visitor tracking cookie. Only ever present for web sessions. + pub maybe_avt_token: Option, +} + +/// 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 + 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 + 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 + 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 + 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, +) -> Result { + 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 { + 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() + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/web_utils/get_authorization_header_mcp_private_session_token.rs b/crates/service/web/storyteller_web/src/http_server/web_utils/get_authorization_header_mcp_private_session_token.rs new file mode 100644 index 0000000000..0e74189b61 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/web_utils/get_authorization_header_mcp_private_session_token.rs @@ -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 `, +/// `Key `, and bare `` 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 { + 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 { + 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()) + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/web_utils/mod.rs b/crates/service/web/storyteller_web/src/http_server/web_utils/mod.rs index f844fa15cd..8403a68409 100644 --- a/crates/service/web/storyteller_web/src/http_server/web_utils/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/web_utils/mod.rs @@ -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;