Skip to main content

codex/
auth.rs

1use std::path::PathBuf;
2
3use tokio::process::Command;
4
5use crate::{
6    capabilities::{guard_is_supported, log_guard_skip},
7    process::{preferred_output_channel, spawn_with_retry},
8    CodexClient, CodexError,
9};
10
11/// Current authentication state reported by `codex login status`.
12#[derive(Clone, Debug, Eq, PartialEq)]
13pub enum CodexAuthStatus {
14    /// The CLI reports an active session.
15    LoggedIn(CodexAuthMethod),
16    /// No credentials stored locally.
17    LoggedOut,
18}
19
20/// Authentication mechanism used to sign in.
21#[derive(Clone, Debug, Eq, PartialEq)]
22pub enum CodexAuthMethod {
23    ChatGpt,
24    ApiKey {
25        masked_key: Option<String>,
26    },
27    /// CLI reported a logged-in state but the auth method could not be parsed (e.g., new wording).
28    Unknown {
29        raw: String,
30    },
31}
32
33/// Result of invoking `codex logout`.
34#[derive(Clone, Debug, Eq, PartialEq)]
35pub enum CodexLogoutStatus {
36    LoggedOut,
37    AlreadyLoggedOut,
38}
39
40/// Helper for checking Codex auth state and triggering login flows with an app-scoped `CODEX_HOME`.
41///
42/// All commands run with per-process env overrides; the parent process env is never mutated.
43#[derive(Clone, Debug)]
44pub struct AuthSessionHelper {
45    client: CodexClient,
46}
47
48impl AuthSessionHelper {
49    /// Creates a helper that pins `CODEX_HOME` to `app_codex_home` for every login call.
50    pub fn new(app_codex_home: impl Into<PathBuf>) -> Self {
51        let client = CodexClient::builder()
52            .codex_home(app_codex_home)
53            .create_home_dirs(true)
54            .build();
55        Self { client }
56    }
57
58    /// Wraps an existing `CodexClient` (useful when you already configured the binary path).
59    pub fn with_client(client: CodexClient) -> Self {
60        Self { client }
61    }
62
63    /// Returns the underlying `CodexClient`.
64    pub fn client(&self) -> CodexClient {
65        self.client.clone()
66    }
67
68    /// Reports the current login status under the configured `CODEX_HOME`.
69    pub async fn status(&self) -> Result<CodexAuthStatus, CodexError> {
70        self.client.login_status().await
71    }
72
73    /// Logs in with an API key when logged out; otherwise returns the current status.
74    pub async fn ensure_api_key_login(
75        &self,
76        api_key: impl AsRef<str>,
77    ) -> Result<CodexAuthStatus, CodexError> {
78        match self.status().await? {
79            logged @ CodexAuthStatus::LoggedIn(_) => Ok(logged),
80            CodexAuthStatus::LoggedOut => self.client.login_with_api_key(api_key).await,
81        }
82    }
83
84    /// Starts the ChatGPT OAuth login flow when no credentials are present.
85    ///
86    /// Returns `Ok(None)` when already logged in; otherwise returns the spawned login child so the
87    /// caller can surface output/URLs. Dropping the child kills the login helper.
88    pub async fn ensure_chatgpt_login(&self) -> Result<Option<tokio::process::Child>, CodexError> {
89        match self.status().await? {
90            CodexAuthStatus::LoggedIn(_) => Ok(None),
91            CodexAuthStatus::LoggedOut => self.client.spawn_login_process().map(Some),
92        }
93    }
94
95    /// Directly spawns the ChatGPT login process.
96    pub fn spawn_chatgpt_login(&self) -> Result<tokio::process::Child, CodexError> {
97        self.client.spawn_login_process()
98    }
99
100    /// Directly logs in with an API key without checking prior state.
101    pub async fn login_with_api_key(
102        &self,
103        api_key: impl AsRef<str>,
104    ) -> Result<CodexAuthStatus, CodexError> {
105        self.client.login_with_api_key(api_key).await
106    }
107}
108
109impl CodexClient {
110    /// Spawns a `codex login` session using the default ChatGPT OAuth flow.
111    ///
112    /// The returned child inherits `kill_on_drop` so abandoning the handle cleans up the login helper.
113    pub fn spawn_login_process(&self) -> Result<tokio::process::Child, CodexError> {
114        let mut command = Command::new(self.command_env.binary_path());
115        command
116            .arg("login")
117            .stdout(std::process::Stdio::piped())
118            .stderr(std::process::Stdio::piped())
119            .kill_on_drop(true);
120
121        self.command_env.apply(&mut command)?;
122
123        spawn_with_retry(&mut command, self.command_env.binary_path())
124    }
125
126    /// Spawns a `codex login --device-auth` session.
127    ///
128    /// The returned child inherits `kill_on_drop` so abandoning the handle cleans up the login helper.
129    pub fn spawn_device_auth_login_process(&self) -> Result<tokio::process::Child, CodexError> {
130        let mut command = Command::new(self.command_env.binary_path());
131        command
132            .arg("login")
133            .arg("--device-auth")
134            .stdout(std::process::Stdio::piped())
135            .stderr(std::process::Stdio::piped())
136            .kill_on_drop(true);
137
138        self.command_env.apply(&mut command)?;
139
140        spawn_with_retry(&mut command, self.command_env.binary_path())
141    }
142
143    /// Spawns a `codex login --with-api-key` session (interactive API-key flow).
144    ///
145    /// The returned child inherits `kill_on_drop` so abandoning the handle cleans up the login helper.
146    pub fn spawn_with_api_key_login_process(&self) -> Result<tokio::process::Child, CodexError> {
147        let mut command = Command::new(self.command_env.binary_path());
148        command
149            .arg("login")
150            .arg("--with-api-key")
151            .stdout(std::process::Stdio::piped())
152            .stderr(std::process::Stdio::piped())
153            .kill_on_drop(true);
154
155        self.command_env.apply(&mut command)?;
156
157        spawn_with_retry(&mut command, self.command_env.binary_path())
158    }
159
160    /// Spawns `codex login --mcp` when the probed binary advertises support.
161    ///
162    /// Returns `Ok(None)` when the capability is unknown or unsupported so
163    /// callers can degrade gracefully without attempting the flag.
164    pub async fn spawn_mcp_login_process(
165        &self,
166    ) -> Result<Option<tokio::process::Child>, CodexError> {
167        let capabilities = self.probe_capabilities().await;
168        let guard = capabilities.guard_mcp_login();
169        if !guard_is_supported(&guard) {
170            log_guard_skip(&guard);
171            return Ok(None);
172        }
173
174        let mut command = Command::new(self.command_env.binary_path());
175        command
176            .arg("login")
177            .arg("--mcp")
178            .stdout(std::process::Stdio::piped())
179            .stderr(std::process::Stdio::piped())
180            .kill_on_drop(true);
181
182        self.command_env.apply(&mut command)?;
183
184        let child = spawn_with_retry(&mut command, self.command_env.binary_path())?;
185
186        Ok(Some(child))
187    }
188
189    /// Logs in with a provided API key by invoking `codex login --api-key <key>`.
190    pub async fn login_with_api_key(
191        &self,
192        api_key: impl AsRef<str>,
193    ) -> Result<CodexAuthStatus, CodexError> {
194        let api_key = api_key.as_ref().trim();
195        if api_key.is_empty() {
196            return Err(CodexError::EmptyApiKey);
197        }
198
199        let output = self
200            .run_basic_command(["login", "--api-key", api_key])
201            .await?;
202        let combined = preferred_output_channel(&output);
203
204        if output.status.success() {
205            Ok(parse_login_success(&combined).unwrap_or_else(|| {
206                CodexAuthStatus::LoggedIn(CodexAuthMethod::Unknown {
207                    raw: combined.clone(),
208                })
209            }))
210        } else {
211            Err(CodexError::NonZeroExit {
212                status: output.status,
213                stderr: combined,
214            })
215        }
216    }
217
218    /// Returns the current Codex authentication state by invoking `codex login status`.
219    pub async fn login_status(&self) -> Result<CodexAuthStatus, CodexError> {
220        let output = self.run_basic_command(["login", "status"]).await?;
221        let combined = preferred_output_channel(&output);
222
223        if output.status.success() {
224            Ok(parse_login_success(&combined).unwrap_or_else(|| {
225                CodexAuthStatus::LoggedIn(CodexAuthMethod::Unknown {
226                    raw: combined.clone(),
227                })
228            }))
229        } else if combined.to_lowercase().contains("not logged in") {
230            Ok(CodexAuthStatus::LoggedOut)
231        } else {
232            Err(CodexError::NonZeroExit {
233                status: output.status,
234                stderr: combined,
235            })
236        }
237    }
238
239    /// Removes cached credentials via `codex logout`.
240    pub async fn logout(&self) -> Result<CodexLogoutStatus, CodexError> {
241        let output = self.run_basic_command(["logout"]).await?;
242        let combined = preferred_output_channel(&output);
243
244        if !output.status.success() {
245            return Err(CodexError::NonZeroExit {
246                status: output.status,
247                stderr: combined,
248            });
249        }
250
251        let normalized = combined.to_lowercase();
252        if normalized.contains("successfully logged out") {
253            Ok(CodexLogoutStatus::LoggedOut)
254        } else if normalized.contains("not logged in") {
255            Ok(CodexLogoutStatus::AlreadyLoggedOut)
256        } else {
257            Ok(CodexLogoutStatus::LoggedOut)
258        }
259    }
260}
261
262pub(crate) fn parse_login_success(output: &str) -> Option<CodexAuthStatus> {
263    let lower = output.to_lowercase();
264    if lower.contains("chatgpt") {
265        return Some(CodexAuthStatus::LoggedIn(CodexAuthMethod::ChatGpt));
266    }
267    if lower.contains("api key") || lower.contains("apikey") {
268        // Prefer everything after the first " - " so we do not chop the key itself.
269        let masked = output
270            .split_once(" - ")
271            .map(|(_, value)| value.trim().to_string())
272            .filter(|value| !value.is_empty())
273            .or_else(|| output.split_whitespace().last().map(|v| v.to_string()));
274        return Some(CodexAuthStatus::LoggedIn(CodexAuthMethod::ApiKey {
275            masked_key: masked,
276        }));
277    }
278    None
279}