From e70ba24bd4ad58735cfa83d3432f1c94d10c2886 Mon Sep 17 00:00:00 2001 From: ctkm-aelf <230958228+ctkm-aelf@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:11:38 +0800 Subject: [PATCH] feat(mcp): serve OpenAI apps domain verification challenge OpenAI's plugin portal verifies control of the MCP host by fetching /.well-known/openai-apps-challenge and expecting the plugin's token as bare plain text. The path returned 404, so submission showed "Domain not verified". Serve the token from the optional OPENAI_APPS_CHALLENGE_TOKEN env var on the public /.well-known router; return 404 when unset, which matches today's behavior. Existing discovery documents are unchanged. Co-Authored-By: Claude Opus 5.5 --- backend/src/config.rs | 13 +++++ backend/src/crypto/aes.rs | 1 + backend/src/crypto/apple_client_secret.rs | 1 + backend/src/crypto/jwt.rs | 1 + backend/src/crypto/local_key_provider.rs | 1 + backend/src/handlers/channel_webhooks.rs | 1 + backend/src/handlers/oidc_discovery.rs | 53 ++++++++++++++++++- backend/src/routes.rs | 4 ++ backend/src/services/channel_relay_service.rs | 1 + backend/src/services/social_auth_service.rs | 1 + backend/src/test_utils.rs | 1 + docs/ENV.md | 1 + 12 files changed, 77 insertions(+), 2 deletions(-) diff --git a/backend/src/config.rs b/backend/src/config.rs index 0da3d65cc..b013d2725 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -374,6 +374,10 @@ pub struct AppConfig { /// Telegram bot username (without @) for link instructions. pub telegram_bot_username: Option, + /// OpenAI plugin-portal domain verification token served verbatim at + /// `/.well-known/openai-apps-challenge`. Unset = route returns 404. + pub openai_apps_challenge_token: Option, + /// Interval in seconds between approval expiry sweeps (default: 5). pub approval_expiry_interval_secs: u64, @@ -717,6 +721,10 @@ impl std::fmt::Debug for AppConfig { .field("telegram_webhook_secret", &"[REDACTED]") .field("telegram_webhook_url", &self.telegram_webhook_url) .field("telegram_bot_username", &self.telegram_bot_username) + .field( + "openai_apps_challenge_token", + &self.openai_apps_challenge_token, + ) .field( "approval_expiry_interval_secs", &self.approval_expiry_interval_secs, @@ -1200,6 +1208,10 @@ impl AppConfig { telegram_bot_username: env::var("TELEGRAM_BOT_USERNAME") .ok() .filter(|s| !s.is_empty()), + openai_apps_challenge_token: env::var("OPENAI_APPS_CHALLENGE_TOKEN") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), approval_expiry_interval_secs: env::var("APPROVAL_EXPIRY_INTERVAL_SECS") .ok() @@ -1946,6 +1958,7 @@ mod tests { telegram_webhook_secret: None, telegram_webhook_url: None, telegram_bot_username: None, + openai_apps_challenge_token: None, approval_expiry_interval_secs: 5, connect_link_expiry_sweep_interval_secs: 60, agent_key_login_sweep_interval_secs: 60, diff --git a/backend/src/crypto/aes.rs b/backend/src/crypto/aes.rs index 11b2ba1bc..d2bbf7cd8 100644 --- a/backend/src/crypto/aes.rs +++ b/backend/src/crypto/aes.rs @@ -1003,6 +1003,7 @@ mod tests { telegram_webhook_secret: None, telegram_webhook_url: None, telegram_bot_username: None, + openai_apps_challenge_token: None, approval_expiry_interval_secs: 5, connect_link_expiry_sweep_interval_secs: 60, agent_key_login_sweep_interval_secs: 60, diff --git a/backend/src/crypto/apple_client_secret.rs b/backend/src/crypto/apple_client_secret.rs index 0d95951ba..e83ac636d 100644 --- a/backend/src/crypto/apple_client_secret.rs +++ b/backend/src/crypto/apple_client_secret.rs @@ -179,6 +179,7 @@ ci0O2dgc19c2/sLtanU7P2KAzhEo8O0tIc0Dwe/nMqKfue82eGVL3DqM\n\ telegram_webhook_secret: None, telegram_webhook_url: None, telegram_bot_username: None, + openai_apps_challenge_token: None, approval_expiry_interval_secs: 5, connect_link_expiry_sweep_interval_secs: 60, agent_key_login_sweep_interval_secs: 60, diff --git a/backend/src/crypto/jwt.rs b/backend/src/crypto/jwt.rs index 08c32bf8a..8f60c0b6f 100644 --- a/backend/src/crypto/jwt.rs +++ b/backend/src/crypto/jwt.rs @@ -1309,6 +1309,7 @@ mod tests { telegram_webhook_secret: None, telegram_webhook_url: None, telegram_bot_username: None, + openai_apps_challenge_token: None, approval_expiry_interval_secs: 5, connect_link_expiry_sweep_interval_secs: 60, agent_key_login_sweep_interval_secs: 60, diff --git a/backend/src/crypto/local_key_provider.rs b/backend/src/crypto/local_key_provider.rs index 9e579f689..23cf408e5 100644 --- a/backend/src/crypto/local_key_provider.rs +++ b/backend/src/crypto/local_key_provider.rs @@ -324,6 +324,7 @@ mod tests { telegram_webhook_secret: None, telegram_webhook_url: None, telegram_bot_username: None, + openai_apps_challenge_token: None, approval_expiry_interval_secs: 5, connect_link_expiry_sweep_interval_secs: 60, agent_key_login_sweep_interval_secs: 60, diff --git a/backend/src/handlers/channel_webhooks.rs b/backend/src/handlers/channel_webhooks.rs index 0af38671a..7bda43a5a 100644 --- a/backend/src/handlers/channel_webhooks.rs +++ b/backend/src/handlers/channel_webhooks.rs @@ -1245,6 +1245,7 @@ mod tests { telegram_webhook_secret: None, telegram_webhook_url: None, telegram_bot_username: None, + openai_apps_challenge_token: None, approval_expiry_interval_secs: 5, connect_link_expiry_sweep_interval_secs: 60, agent_key_login_sweep_interval_secs: 60, diff --git a/backend/src/handlers/oidc_discovery.rs b/backend/src/handlers/oidc_discovery.rs index 2868e2341..85d638018 100644 --- a/backend/src/handlers/oidc_discovery.rs +++ b/backend/src/handlers/oidc_discovery.rs @@ -1,4 +1,9 @@ -use axum::{Json, extract::State}; +use axum::{ + Json, + extract::State, + http::{StatusCode, header}, + response::{IntoResponse, Response}, +}; use crate::AppState; use crate::services::oauth_broker_service; @@ -136,11 +141,27 @@ pub async fn jwks(State(state): State) -> Json { })) } +/// GET /.well-known/openai-apps-challenge +/// +/// OpenAI plugin-portal domain verification for the MCP host. Returns the +/// configured token as bare plain text, or 404 when none is configured. +pub async fn openai_apps_challenge(State(state): State) -> Response { + match &state.config.openai_apps_challenge_token { + Some(token) => ( + [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], + token.clone(), + ) + .into_response(), + None => StatusCode::NOT_FOUND.into_response(), + } +} + #[cfg(test)] mod tests { use super::{ OAUTH_AUTHORIZATION_SERVER_SCOPES_SUPPORTED, OPENID_CONFIGURATION_SCOPES_SUPPORTED, - oauth_authorization_server_metadata, oauth_protected_resource, openid_configuration, + oauth_authorization_server_metadata, oauth_protected_resource, openai_apps_challenge, + openid_configuration, }; use crate::services::oauth_broker_service::{BROKER_BINDING_SCOPE, BROKER_SUBJECT_TOKEN_TYPE}; use axum::extract::State; @@ -151,6 +172,34 @@ mod tests { assert!(!OAUTH_AUTHORIZATION_SERVER_SCOPES_SUPPORTED.contains(&BROKER_BINDING_SCOPE)); } + #[tokio::test] + async fn openai_apps_challenge_serves_configured_token_as_plain_text() { + let mut state = crate::test_utils::test_app_state_no_db().await; + state.config.openai_apps_challenge_token = Some("challenge-token-123".to_string()); + + let response = openai_apps_challenge(State(state)).await; + + assert_eq!(response.status(), axum::http::StatusCode::OK); + assert_eq!( + response.headers()[axum::http::header::CONTENT_TYPE], + "text/plain; charset=utf-8" + ); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + assert_eq!(&body[..], b"challenge-token-123"); + } + + #[tokio::test] + async fn openai_apps_challenge_is_not_found_when_unconfigured() { + let mut state = crate::test_utils::test_app_state_no_db().await; + state.config.openai_apps_challenge_token = None; + + let response = openai_apps_challenge(State(state)).await; + + assert_eq!(response.status(), axum::http::StatusCode::NOT_FOUND); + } + #[tokio::test] async fn protected_resource_metadata_advertises_mcp_default_scopes() { // NyxID#1226: clients pick their requested scopes from this list, so diff --git a/backend/src/routes.rs b/backend/src/routes.rs index c48665125..ff95253e5 100644 --- a/backend/src/routes.rs +++ b/backend/src/routes.rs @@ -2353,6 +2353,10 @@ fn build_router_internal(router_state: Option) -> (Router, R .route( "/oauth-protected-resource", get(handlers::oidc_discovery::oauth_protected_resource), + ) + .route( + "/openai-apps-challenge", + get(handlers::oidc_discovery::openai_apps_challenge), ); let public_oauth = Router::new() diff --git a/backend/src/services/channel_relay_service.rs b/backend/src/services/channel_relay_service.rs index cb3fcab64..facaab624 100644 --- a/backend/src/services/channel_relay_service.rs +++ b/backend/src/services/channel_relay_service.rs @@ -1501,6 +1501,7 @@ mod tests { telegram_webhook_secret: None, telegram_webhook_url: None, telegram_bot_username: None, + openai_apps_challenge_token: None, approval_expiry_interval_secs: 5, connect_link_expiry_sweep_interval_secs: 60, agent_key_login_sweep_interval_secs: 60, diff --git a/backend/src/services/social_auth_service.rs b/backend/src/services/social_auth_service.rs index 19a8cbfed..265b50cb7 100644 --- a/backend/src/services/social_auth_service.rs +++ b/backend/src/services/social_auth_service.rs @@ -821,6 +821,7 @@ mod tests { telegram_webhook_secret: None, telegram_webhook_url: None, telegram_bot_username: None, + openai_apps_challenge_token: None, approval_expiry_interval_secs: 5, connect_link_expiry_sweep_interval_secs: 60, agent_key_login_sweep_interval_secs: 60, diff --git a/backend/src/test_utils.rs b/backend/src/test_utils.rs index cebbb8c20..36edfa74a 100644 --- a/backend/src/test_utils.rs +++ b/backend/src/test_utils.rs @@ -1752,6 +1752,7 @@ pub(crate) fn test_app_config() -> AppConfig { telegram_webhook_secret: None, telegram_webhook_url: None, telegram_bot_username: None, + openai_apps_challenge_token: None, approval_expiry_interval_secs: 5, connect_link_expiry_sweep_interval_secs: 60, agent_key_login_sweep_interval_secs: 60, diff --git a/docs/ENV.md b/docs/ENV.md index 452e846b9..b115361fe 100644 --- a/docs/ENV.md +++ b/docs/ENV.md @@ -381,6 +381,7 @@ Requires all four values. Create a Services ID and key at the [Apple Developer p | `TELEGRAM_WEBHOOK_SECRET` | | Secret for verifying Telegram webhook callbacks | | `TELEGRAM_WEBHOOK_URL` | | Public URL for Telegram webhooks (e.g. `https://auth.nyxid.dev/api/v1/webhooks/telegram`). Omit to use long polling mode. | | `TELEGRAM_BOT_USERNAME` | | Bot username without @ (for link instructions) | +| `OPENAI_APPS_CHALLENGE_TOKEN` | | OpenAI plugin-portal domain verification token, served as plain text at `/.well-known/openai-apps-challenge` on the MCP host. Unset = 404 | | `APPROVAL_EXPIRY_INTERVAL_SECS` | `5` | Interval between approval expiry sweeps (seconds) | The approval system works without Telegram -- users can always approve/reject via the web UI. Telegram delivery requires `TELEGRAM_BOT_TOKEN`.