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 a `codex login --with-access-token` session.
161    ///
162    /// The returned child inherits `kill_on_drop` so abandoning the handle cleans up the login helper.
163    pub fn spawn_with_access_token_login_process(
164        &self,
165    ) -> Result<tokio::process::Child, CodexError> {
166        let mut command = Command::new(self.command_env.binary_path());
167        command
168            .arg("login")
169            .arg("--with-access-token")
170            .stdout(std::process::Stdio::piped())
171            .stderr(std::process::Stdio::piped())
172            .kill_on_drop(true);
173
174        self.command_env.apply(&mut command)?;
175
176        spawn_with_retry(&mut command, self.command_env.binary_path())
177    }
178
179    /// Spawns `codex login --mcp` when the probed binary advertises support.
180    ///
181    /// Returns `Ok(None)` when the capability is unknown or unsupported so
182    /// callers can degrade gracefully without attempting the flag.
183    pub async fn spawn_mcp_login_process(
184        &self,
185    ) -> Result<Option<tokio::process::Child>, CodexError> {
186        let capabilities = self.probe_capabilities().await;
187        let guard = capabilities.guard_mcp_login();
188        if !guard_is_supported(&guard) {
189            log_guard_skip(&guard);
190            return Ok(None);
191        }
192
193        let mut command = Command::new(self.command_env.binary_path());
194        command
195            .arg("login")
196            .arg("--mcp")
197            .stdout(std::process::Stdio::piped())
198            .stderr(std::process::Stdio::piped())
199            .kill_on_drop(true);
200
201        self.command_env.apply(&mut command)?;
202
203        let child = spawn_with_retry(&mut command, self.command_env.binary_path())?;
204
205        Ok(Some(child))
206    }
207
208    /// Logs in with a provided API key by invoking `codex login --api-key <key>`.
209    pub async fn login_with_api_key(
210        &self,
211        api_key: impl AsRef<str>,
212    ) -> Result<CodexAuthStatus, CodexError> {
213        let api_key = api_key.as_ref().trim();
214        if api_key.is_empty() {
215            return Err(CodexError::EmptyApiKey);
216        }
217
218        let output = self
219            .run_basic_command(["login", "--api-key", api_key])
220            .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 {
230            Err(CodexError::NonZeroExit {
231                status: output.status,
232                stderr: combined,
233            })
234        }
235    }
236
237    /// Returns the current Codex authentication state by invoking `codex login status`.
238    pub async fn login_status(&self) -> Result<CodexAuthStatus, CodexError> {
239        let output = self.run_basic_command(["login", "status"]).await?;
240        let combined = preferred_output_channel(&output);
241
242        if output.status.success() {
243            Ok(parse_login_success(&combined).unwrap_or_else(|| {
244                CodexAuthStatus::LoggedIn(CodexAuthMethod::Unknown {
245                    raw: combined.clone(),
246                })
247            }))
248        } else if combined.to_lowercase().contains("not logged in") {
249            Ok(CodexAuthStatus::LoggedOut)
250        } else {
251            Err(CodexError::NonZeroExit {
252                status: output.status,
253                stderr: combined,
254            })
255        }
256    }
257
258    /// Removes cached credentials via `codex logout`.
259    pub async fn logout(&self) -> Result<CodexLogoutStatus, CodexError> {
260        let output = self.run_basic_command(["logout"]).await?;
261        let combined = preferred_output_channel(&output);
262
263        if !output.status.success() {
264            return Err(CodexError::NonZeroExit {
265                status: output.status,
266                stderr: combined,
267            });
268        }
269
270        let normalized = combined.to_lowercase();
271        if normalized.contains("successfully logged out") {
272            Ok(CodexLogoutStatus::LoggedOut)
273        } else if normalized.contains("not logged in") {
274            Ok(CodexLogoutStatus::AlreadyLoggedOut)
275        } else {
276            Ok(CodexLogoutStatus::LoggedOut)
277        }
278    }
279}
280
281pub(crate) fn parse_login_success(output: &str) -> Option<CodexAuthStatus> {
282    let lower = output.to_lowercase();
283    if lower.contains("chatgpt") {
284        return Some(CodexAuthStatus::LoggedIn(CodexAuthMethod::ChatGpt));
285    }
286    if lower.contains("api key") || lower.contains("apikey") {
287        // Prefer everything after the first " - " so we do not chop the key itself.
288        let masked = output
289            .split_once(" - ")
290            .map(|(_, value)| value.trim().to_string())
291            .filter(|value| !value.is_empty())
292            .or_else(|| output.split_whitespace().last().map(|v| v.to_string()));
293        return Some(CodexAuthStatus::LoggedIn(CodexAuthMethod::ApiKey {
294            masked_key: masked,
295        }));
296    }
297    None
298}