Skip to main content

codex_mcp/
auth_elicitation.rs

1//! Auth elicitation helpers.
2//!
3//! This module owns protocol-neutral auth elicitation parsing and payload shaping.
4//! Session orchestration stays in `codex-core`.
5
6use codex_protocol::mcp::CallToolResult;
7use serde::Serialize;
8
9pub const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps";
10pub const CONNECTOR_AUTH_FAILURE_META_KEY: &str = "connector_auth_failure";
11pub const CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: &str = "is_auth_failure";
12pub const CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: &str = "auth_reason";
13pub const CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: &str = "connector_id";
14pub const CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: &str = "link_id";
15pub const CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: &str = "error_code";
16pub const CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: &str = "error_http_status_code";
17pub const CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: &str = "error_action";
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct CodexAppsConnectorAuthFailure {
21    pub connector_id: String,
22    pub connector_name: String,
23    pub install_url: String,
24    pub auth_reason: Option<String>,
25    pub link_id: Option<String>,
26    pub error_code: Option<String>,
27    pub error_http_status_code: Option<i64>,
28    pub error_action: Option<String>,
29}
30
31#[derive(Debug, Clone, PartialEq)]
32pub struct CodexAppsAuthElicitation {
33    pub meta: serde_json::Value,
34    pub message: String,
35    pub url: String,
36    pub elicitation_id: String,
37}
38
39#[derive(Debug, Clone, PartialEq)]
40pub struct CodexAppsAuthElicitationPlan {
41    pub auth_failure: CodexAppsConnectorAuthFailure,
42    pub elicitation: CodexAppsAuthElicitation,
43}
44
45#[derive(Serialize)]
46struct CodexAppsConnectorAuthFailureMeta<'a> {
47    is_auth_failure: bool,
48    connector_id: &'a str,
49    connector_name: &'a str,
50    install_url: &'a str,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    auth_reason: Option<&'a str>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    link_id: Option<&'a str>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    error_code: Option<&'a str>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    error_http_status_code: Option<i64>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    error_action: Option<&'a str>,
61}
62
63pub fn connector_auth_failure_from_tool_result(
64    result: &CallToolResult,
65    connector_id: Option<&str>,
66    connector_name: Option<&str>,
67    install_url: Option<String>,
68) -> Option<CodexAppsConnectorAuthFailure> {
69    if result.is_error != Some(true) {
70        return None;
71    }
72
73    let auth_failure = result
74        .meta
75        .as_ref()?
76        .as_object()?
77        .get(MCP_TOOL_CODEX_APPS_META_KEY)?
78        .as_object()?
79        .get(CONNECTOR_AUTH_FAILURE_META_KEY)?
80        .as_object()?;
81    if auth_failure
82        .get(CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY)
83        .and_then(serde_json::Value::as_bool)
84        != Some(true)
85    {
86        return None;
87    }
88
89    let connector_id = connector_id
90        .map(str::trim)
91        .filter(|connector_id| !connector_id.is_empty())?;
92    if let Some(auth_failure_connector_id) =
93        string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY)
94        && auth_failure_connector_id != connector_id
95    {
96        return None;
97    }
98    let connector_name = connector_name
99        .map(str::trim)
100        .filter(|name| !name.is_empty())
101        .unwrap_or(connector_id)
102        .to_string();
103
104    Some(CodexAppsConnectorAuthFailure {
105        connector_id: connector_id.to_string(),
106        connector_name,
107        install_url: install_url?,
108        auth_reason: string_auth_failure_field(
109            auth_failure,
110            CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY,
111        ),
112        link_id: string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_LINK_ID_KEY),
113        error_code: string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY),
114        error_http_status_code: auth_failure
115            .get(CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY)
116            .and_then(serde_json::Value::as_i64),
117        error_action: string_auth_failure_field(
118            auth_failure,
119            CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY,
120        ),
121    })
122}
123
124pub fn build_auth_elicitation_plan(
125    call_id: &str,
126    result: &CallToolResult,
127    connector_id: Option<&str>,
128    connector_name: Option<&str>,
129    install_url: Option<String>,
130) -> Option<CodexAppsAuthElicitationPlan> {
131    let auth_failure =
132        connector_auth_failure_from_tool_result(result, connector_id, connector_name, install_url)?;
133    let elicitation = build_auth_elicitation(call_id, &auth_failure);
134    Some(CodexAppsAuthElicitationPlan {
135        auth_failure,
136        elicitation,
137    })
138}
139
140pub fn build_auth_elicitation(
141    call_id: &str,
142    auth_failure: &CodexAppsConnectorAuthFailure,
143) -> CodexAppsAuthElicitation {
144    CodexAppsAuthElicitation {
145        meta: serde_json::json!({
146            MCP_TOOL_CODEX_APPS_META_KEY: {
147                CONNECTOR_AUTH_FAILURE_META_KEY: CodexAppsConnectorAuthFailureMeta {
148                    is_auth_failure: true,
149                    connector_id: &auth_failure.connector_id,
150                    connector_name: &auth_failure.connector_name,
151                    install_url: &auth_failure.install_url,
152                    auth_reason: auth_failure.auth_reason.as_deref(),
153                    link_id: auth_failure.link_id.as_deref(),
154                    error_code: auth_failure.error_code.as_deref(),
155                    error_http_status_code: auth_failure.error_http_status_code,
156                    error_action: auth_failure.error_action.as_deref(),
157                },
158            },
159        }),
160        message: auth_elicitation_message(auth_failure),
161        url: auth_failure.install_url.clone(),
162        elicitation_id: auth_elicitation_id(call_id),
163    }
164}
165
166pub fn auth_elicitation_completed_result(
167    auth_failure: &CodexAppsConnectorAuthFailure,
168    meta: Option<serde_json::Value>,
169) -> CallToolResult {
170    CallToolResult {
171        content: vec![serde_json::json!({
172            "type": "text",
173            "text": format!(
174                "Authentication for {} was requested and accepted. Retry this tool call now.",
175                auth_failure.connector_name
176            ),
177        })],
178        structured_content: None,
179        is_error: Some(true),
180        meta,
181    }
182}
183
184pub fn auth_elicitation_id(call_id: &str) -> String {
185    format!("codex_apps_auth_{call_id}")
186}
187
188fn string_auth_failure_field(
189    auth_failure: &serde_json::Map<String, serde_json::Value>,
190    key: &str,
191) -> Option<String> {
192    auth_failure
193        .get(key)
194        .and_then(serde_json::Value::as_str)
195        .map(str::trim)
196        .filter(|value| !value.is_empty())
197        .map(ToString::to_string)
198}
199
200fn auth_elicitation_message(auth_failure: &CodexAppsConnectorAuthFailure) -> String {
201    match auth_failure.auth_reason.as_deref() {
202        Some("oauth_upgrade_required") => format!(
203            "Reconnect {} on ChatGPT to grant the permissions needed for this request.",
204            auth_failure.connector_name
205        ),
206        Some("reauthentication_required") => format!(
207            "Reconnect {} on ChatGPT to restore access for this request.",
208            auth_failure.connector_name
209        ),
210        Some("missing_link") => format!(
211            "Sign in to {} on ChatGPT to use it in Codex.",
212            auth_failure.connector_name
213        ),
214        _ => format!(
215            "Sign in to {} on ChatGPT to continue.",
216            auth_failure.connector_name
217        ),
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use pretty_assertions::assert_eq;
225
226    fn auth_failure_result() -> CallToolResult {
227        CallToolResult {
228            content: vec![serde_json::json!({
229                "type": "text",
230                "text": "Connector reauthentication required",
231            })],
232            structured_content: None,
233            is_error: Some(true),
234            meta: Some(serde_json::json!({
235                MCP_TOOL_CODEX_APPS_META_KEY: {
236                    CONNECTOR_AUTH_FAILURE_META_KEY: {
237                        CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: true,
238                        CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: "reauthentication_required",
239                        CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: "connector_calendar",
240                        "connector_name": "Untrusted Calendar",
241                        CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: "link_123",
242                        CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: "UNAUTHORIZED",
243                        CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: 401,
244                        CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: "TRIGGER_REAUTHENTICATION",
245                    },
246                },
247            })),
248        }
249    }
250
251    #[test]
252    fn parses_auth_failure_from_trusted_connector_metadata() {
253        assert_eq!(
254            connector_auth_failure_from_tool_result(
255                &auth_failure_result(),
256                Some("connector_calendar"),
257                Some("Google Calendar"),
258                Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()),
259            ),
260            Some(CodexAppsConnectorAuthFailure {
261                connector_id: "connector_calendar".to_string(),
262                connector_name: "Google Calendar".to_string(),
263                install_url: "https://chatgpt.com/apps/google-calendar/connector_calendar"
264                    .to_string(),
265                auth_reason: Some("reauthentication_required".to_string()),
266                link_id: Some("link_123".to_string()),
267                error_code: Some("UNAUTHORIZED".to_string()),
268                error_http_status_code: Some(401),
269                error_action: Some("TRIGGER_REAUTHENTICATION".to_string()),
270            })
271        );
272    }
273
274    #[test]
275    fn rejects_missing_or_mismatched_connector_ids() {
276        assert_eq!(
277            connector_auth_failure_from_tool_result(
278                &auth_failure_result(),
279                /*connector_id*/ None,
280                Some("Google Calendar"),
281                Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()),
282            ),
283            None
284        );
285        assert_eq!(
286            connector_auth_failure_from_tool_result(
287                &auth_failure_result(),
288                Some("connector_drive"),
289                Some("Google Drive"),
290                Some("https://chatgpt.com/apps/google-drive/connector_drive".to_string()),
291            ),
292            None
293        );
294    }
295
296    #[test]
297    fn builds_url_elicitation_payload() {
298        let auth_failure = connector_auth_failure_from_tool_result(
299            &auth_failure_result(),
300            Some("connector_calendar"),
301            Some("Google Calendar"),
302            Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()),
303        )
304        .expect("auth failure");
305
306        assert_eq!(
307            build_auth_elicitation("call_123", &auth_failure),
308            CodexAppsAuthElicitation {
309                meta: serde_json::json!({
310                    MCP_TOOL_CODEX_APPS_META_KEY: {
311                        CONNECTOR_AUTH_FAILURE_META_KEY: {
312                            CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: true,
313                            CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: "connector_calendar",
314                            "connector_name": "Google Calendar",
315                            "install_url":
316                                "https://chatgpt.com/apps/google-calendar/connector_calendar",
317                            CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: "reauthentication_required",
318                            CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: "link_123",
319                            CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: "UNAUTHORIZED",
320                            CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: 401,
321                            CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: "TRIGGER_REAUTHENTICATION",
322                        },
323                    },
324                }),
325                message: "Reconnect Google Calendar on ChatGPT to restore access for this request."
326                    .to_string(),
327                url: "https://chatgpt.com/apps/google-calendar/connector_calendar".to_string(),
328                elicitation_id: "codex_apps_auth_call_123".to_string(),
329            }
330        );
331    }
332
333    #[test]
334    fn builds_auth_elicitation_plan() {
335        let plan = build_auth_elicitation_plan(
336            "call_123",
337            &auth_failure_result(),
338            Some("connector_calendar"),
339            Some("Google Calendar"),
340            Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()),
341        )
342        .expect("auth elicitation plan");
343
344        assert_eq!(plan.auth_failure.connector_name, "Google Calendar");
345        assert_eq!(plan.elicitation.elicitation_id, "codex_apps_auth_call_123");
346    }
347}