Codex Handbook
codex-mcp/src/auth_elicitation.rs 347 lines
//! Auth elicitation helpers.//!//! This module owns protocol-neutral auth elicitation parsing and payload shaping.//! Session orchestration stays in `codex-core`.use codex_protocol::mcp::CallToolResult;use serde::Serialize;pub const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps";pub const CONNECTOR_AUTH_FAILURE_META_KEY: &str = "connector_auth_failure";pub const CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: &str = "is_auth_failure";pub const CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: &str = "auth_reason";pub const CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: &str = "connector_id";pub const CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: &str = "link_id";pub const CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: &str = "error_code";pub const CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: &str = "error_http_status_code";pub const CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: &str = "error_action";#[derive(Debug, Clone, PartialEq, Eq)]pub struct CodexAppsConnectorAuthFailure {    pub connector_id: String,    pub connector_name: String,    pub install_url: String,    pub auth_reason: Option<String>,    pub link_id: Option<String>,    pub error_code: Option<String>,    pub error_http_status_code: Option<i64>,    pub error_action: Option<String>,}#[derive(Debug, Clone, PartialEq)]pub struct CodexAppsAuthElicitation {    pub meta: serde_json::Value,    pub message: String,    pub url: String,    pub elicitation_id: String,}#[derive(Debug, Clone, PartialEq)]pub struct CodexAppsAuthElicitationPlan {    pub auth_failure: CodexAppsConnectorAuthFailure,    pub elicitation: CodexAppsAuthElicitation,}#[derive(Serialize)]struct CodexAppsConnectorAuthFailureMeta<'a> {    is_auth_failure: bool,    connector_id: &'a str,    connector_name: &'a str,    install_url: &'a str,    #[serde(skip_serializing_if = "Option::is_none")]    auth_reason: Option<&'a str>,    #[serde(skip_serializing_if = "Option::is_none")]    link_id: Option<&'a str>,    #[serde(skip_serializing_if = "Option::is_none")]    error_code: Option<&'a str>,    #[serde(skip_serializing_if = "Option::is_none")]    error_http_status_code: Option<i64>,    #[serde(skip_serializing_if = "Option::is_none")]    error_action: Option<&'a str>,}pub fn connector_auth_failure_from_tool_result(    result: &CallToolResult,    connector_id: Option<&str>,    connector_name: Option<&str>,    install_url: Option<String>,) -> Option<CodexAppsConnectorAuthFailure> {    if result.is_error != Some(true) {        return None;    }    let auth_failure = result        .meta        .as_ref()?        .as_object()?        .get(MCP_TOOL_CODEX_APPS_META_KEY)?        .as_object()?        .get(CONNECTOR_AUTH_FAILURE_META_KEY)?        .as_object()?;    if auth_failure        .get(CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY)        .and_then(serde_json::Value::as_bool)        != Some(true)    {        return None;    }    let connector_id = connector_id        .map(str::trim)        .filter(|connector_id| !connector_id.is_empty())?;    if let Some(auth_failure_connector_id) =        string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY)        && auth_failure_connector_id != connector_id    {        return None;    }    let connector_name = connector_name        .map(str::trim)        .filter(|name| !name.is_empty())        .unwrap_or(connector_id)        .to_string();    Some(CodexAppsConnectorAuthFailure {        connector_id: connector_id.to_string(),        connector_name,        install_url: install_url?,        auth_reason: string_auth_failure_field(            auth_failure,            CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY,        ),        link_id: string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_LINK_ID_KEY),        error_code: string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY),        error_http_status_code: auth_failure            .get(CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY)            .and_then(serde_json::Value::as_i64),        error_action: string_auth_failure_field(            auth_failure,            CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY,        ),    })}pub fn build_auth_elicitation_plan(    call_id: &str,    result: &CallToolResult,    connector_id: Option<&str>,    connector_name: Option<&str>,    install_url: Option<String>,) -> Option<CodexAppsAuthElicitationPlan> {    let auth_failure =        connector_auth_failure_from_tool_result(result, connector_id, connector_name, install_url)?;    let elicitation = build_auth_elicitation(call_id, &auth_failure);    Some(CodexAppsAuthElicitationPlan {        auth_failure,        elicitation,    })}pub fn build_auth_elicitation(    call_id: &str,    auth_failure: &CodexAppsConnectorAuthFailure,) -> CodexAppsAuthElicitation {    CodexAppsAuthElicitation {        meta: serde_json::json!({            MCP_TOOL_CODEX_APPS_META_KEY: {                CONNECTOR_AUTH_FAILURE_META_KEY: CodexAppsConnectorAuthFailureMeta {                    is_auth_failure: true,                    connector_id: &auth_failure.connector_id,                    connector_name: &auth_failure.connector_name,                    install_url: &auth_failure.install_url,                    auth_reason: auth_failure.auth_reason.as_deref(),                    link_id: auth_failure.link_id.as_deref(),                    error_code: auth_failure.error_code.as_deref(),                    error_http_status_code: auth_failure.error_http_status_code,                    error_action: auth_failure.error_action.as_deref(),                },            },        }),        message: auth_elicitation_message(auth_failure),        url: auth_failure.install_url.clone(),        elicitation_id: auth_elicitation_id(call_id),    }}pub fn auth_elicitation_completed_result(    auth_failure: &CodexAppsConnectorAuthFailure,    meta: Option<serde_json::Value>,) -> CallToolResult {    CallToolResult {        content: vec![serde_json::json!({            "type": "text",            "text": format!(                "Authentication for {} was requested and accepted. Retry this tool call now.",                auth_failure.connector_name            ),        })],        structured_content: None,        is_error: Some(true),        meta,    }}pub fn auth_elicitation_id(call_id: &str) -> String {    format!("codex_apps_auth_{call_id}")}fn string_auth_failure_field(    auth_failure: &serde_json::Map<String, serde_json::Value>,    key: &str,) -> Option<String> {    auth_failure        .get(key)        .and_then(serde_json::Value::as_str)        .map(str::trim)        .filter(|value| !value.is_empty())        .map(ToString::to_string)}fn auth_elicitation_message(auth_failure: &CodexAppsConnectorAuthFailure) -> String {    match auth_failure.auth_reason.as_deref() {        Some("oauth_upgrade_required") => format!(            "Reconnect {} on ChatGPT to grant the permissions needed for this request.",            auth_failure.connector_name        ),        Some("reauthentication_required") => format!(            "Reconnect {} on ChatGPT to restore access for this request.",            auth_failure.connector_name        ),        Some("missing_link") => format!(            "Sign in to {} on ChatGPT to use it in Codex.",            auth_failure.connector_name        ),        _ => format!(            "Sign in to {} on ChatGPT to continue.",            auth_failure.connector_name        ),    }}#[cfg(test)]mod tests {    use super::*;    use pretty_assertions::assert_eq;    fn auth_failure_result() -> CallToolResult {        CallToolResult {            content: vec![serde_json::json!({                "type": "text",                "text": "Connector reauthentication required",            })],            structured_content: None,            is_error: Some(true),            meta: Some(serde_json::json!({                MCP_TOOL_CODEX_APPS_META_KEY: {                    CONNECTOR_AUTH_FAILURE_META_KEY: {                        CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: true,                        CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: "reauthentication_required",                        CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: "connector_calendar",                        "connector_name": "Untrusted Calendar",                        CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: "link_123",                        CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: "UNAUTHORIZED",                        CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: 401,                        CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: "TRIGGER_REAUTHENTICATION",                    },                },            })),        }    }    #[test]    fn parses_auth_failure_from_trusted_connector_metadata() {        assert_eq!(            connector_auth_failure_from_tool_result(                &auth_failure_result(),                Some("connector_calendar"),                Some("Google Calendar"),                Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()),            ),            Some(CodexAppsConnectorAuthFailure {                connector_id: "connector_calendar".to_string(),                connector_name: "Google Calendar".to_string(),                install_url: "https://chatgpt.com/apps/google-calendar/connector_calendar"                    .to_string(),                auth_reason: Some("reauthentication_required".to_string()),                link_id: Some("link_123".to_string()),                error_code: Some("UNAUTHORIZED".to_string()),                error_http_status_code: Some(401),                error_action: Some("TRIGGER_REAUTHENTICATION".to_string()),            })        );    }    #[test]    fn rejects_missing_or_mismatched_connector_ids() {        assert_eq!(            connector_auth_failure_from_tool_result(                &auth_failure_result(),                /*connector_id*/ None,                Some("Google Calendar"),                Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()),            ),            None        );        assert_eq!(            connector_auth_failure_from_tool_result(                &auth_failure_result(),                Some("connector_drive"),                Some("Google Drive"),                Some("https://chatgpt.com/apps/google-drive/connector_drive".to_string()),            ),            None        );    }    #[test]    fn builds_url_elicitation_payload() {        let auth_failure = connector_auth_failure_from_tool_result(            &auth_failure_result(),            Some("connector_calendar"),            Some("Google Calendar"),            Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()),        )        .expect("auth failure");        assert_eq!(            build_auth_elicitation("call_123", &auth_failure),            CodexAppsAuthElicitation {                meta: serde_json::json!({                    MCP_TOOL_CODEX_APPS_META_KEY: {                        CONNECTOR_AUTH_FAILURE_META_KEY: {                            CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: true,                            CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: "connector_calendar",                            "connector_name": "Google Calendar",                            "install_url":                                "https://chatgpt.com/apps/google-calendar/connector_calendar",                            CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: "reauthentication_required",                            CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: "link_123",                            CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: "UNAUTHORIZED",                            CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: 401,                            CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: "TRIGGER_REAUTHENTICATION",                        },                    },                }),                message: "Reconnect Google Calendar on ChatGPT to restore access for this request."                    .to_string(),                url: "https://chatgpt.com/apps/google-calendar/connector_calendar".to_string(),                elicitation_id: "codex_apps_auth_call_123".to_string(),            }        );    }    #[test]    fn builds_auth_elicitation_plan() {        let plan = build_auth_elicitation_plan(            "call_123",            &auth_failure_result(),            Some("connector_calendar"),            Some("Google Calendar"),            Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()),        )        .expect("auth elicitation plan");        assert_eq!(plan.auth_failure.connector_name, "Google Calendar");        assert_eq!(plan.elicitation.elicitation_id, "codex_apps_auth_call_123");    }}