Skip to main content

codex_login/
device_code_auth.rs

1use codex_http_client::HttpClient;
2use http::StatusCode;
3use serde::Deserialize;
4use serde::Serialize;
5use serde::de::Deserializer;
6use serde::de::{self};
7use std::time::Duration;
8use std::time::Instant;
9
10use crate::default_client::create_raw_auth_client;
11use crate::pkce::PkceCodes;
12use crate::server::ServerOptions;
13use std::io;
14
15const ANSI_BLUE: &str = "\x1b[94m";
16const ANSI_GRAY: &str = "\x1b[90m";
17const ANSI_RESET: &str = "\x1b[0m";
18
19#[derive(Debug, Clone)]
20pub struct DeviceCode {
21    pub verification_url: String,
22    pub user_code: String,
23    device_auth_id: String,
24    interval: u64,
25}
26
27#[derive(Deserialize)]
28struct UserCodeResp {
29    device_auth_id: String,
30    #[serde(alias = "user_code", alias = "usercode")]
31    user_code: String,
32    #[serde(default, deserialize_with = "deserialize_interval")]
33    interval: u64,
34}
35
36#[derive(Serialize)]
37struct UserCodeReq {
38    client_id: String,
39}
40
41#[derive(Serialize)]
42struct TokenPollReq {
43    device_auth_id: String,
44    user_code: String,
45}
46
47fn deserialize_interval<'de, D>(deserializer: D) -> Result<u64, D::Error>
48where
49    D: Deserializer<'de>,
50{
51    let s = String::deserialize(deserializer)?;
52    s.trim().parse::<u64>().map_err(de::Error::custom)
53}
54
55#[derive(Deserialize)]
56struct CodeSuccessResp {
57    authorization_code: String,
58    code_challenge: String,
59    code_verifier: String,
60}
61
62/// Request the user code and polling interval.
63async fn request_user_code(
64    client: &HttpClient,
65    auth_base_url: &str,
66    client_id: &str,
67) -> std::io::Result<UserCodeResp> {
68    let url = format!("{auth_base_url}/deviceauth/usercode");
69    let body = serde_json::to_string(&UserCodeReq {
70        client_id: client_id.to_string(),
71    })
72    .map_err(std::io::Error::other)?;
73    let resp = client
74        .post(url)
75        .header("Content-Type", "application/json")
76        .body(body)
77        .send()
78        .await
79        .map_err(std::io::Error::other)?;
80
81    if !resp.status().is_success() {
82        let status = resp.status();
83        if status == StatusCode::NOT_FOUND {
84            return Err(io::Error::new(
85                io::ErrorKind::NotFound,
86                "device code login is not enabled for this Codex server. Use the browser login or verify the server URL.",
87            ));
88        }
89
90        return Err(std::io::Error::other(format!(
91            "device code request failed with status {status}"
92        )));
93    }
94
95    let body = resp.text().await.map_err(std::io::Error::other)?;
96    serde_json::from_str(&body).map_err(std::io::Error::other)
97}
98
99/// Poll token endpoint until a code is issued or timeout occurs.
100async fn poll_for_token(
101    client: &HttpClient,
102    auth_base_url: &str,
103    device_auth_id: &str,
104    user_code: &str,
105    interval: u64,
106) -> std::io::Result<CodeSuccessResp> {
107    let url = format!("{auth_base_url}/deviceauth/token");
108    let max_wait = Duration::from_secs(15 * 60);
109    let start = Instant::now();
110
111    loop {
112        let body = serde_json::to_string(&TokenPollReq {
113            device_auth_id: device_auth_id.to_string(),
114            user_code: user_code.to_string(),
115        })
116        .map_err(std::io::Error::other)?;
117        let resp = client
118            .post(&url)
119            .header("Content-Type", "application/json")
120            .body(body)
121            .send()
122            .await
123            .map_err(std::io::Error::other)?;
124
125        let status = resp.status();
126
127        if status.is_success() {
128            return resp.json().await.map_err(std::io::Error::other);
129        }
130
131        if status == StatusCode::FORBIDDEN || status == StatusCode::NOT_FOUND {
132            if start.elapsed() >= max_wait {
133                return Err(std::io::Error::other(
134                    "device auth timed out after 15 minutes",
135                ));
136            }
137            let sleep_for = Duration::from_secs(interval).min(max_wait - start.elapsed());
138            tokio::time::sleep(sleep_for).await;
139            continue;
140        }
141
142        return Err(std::io::Error::other(format!(
143            "device auth failed with status {}",
144            resp.status()
145        )));
146    }
147}
148
149fn device_code_prompt(verification_url: &str, code: &str) -> String {
150    let version = env!("CARGO_PKG_VERSION");
151    format!(
152        "\nWelcome to Codex [v{ANSI_GRAY}{version}{ANSI_RESET}]\n{ANSI_GRAY}OpenAI's command-line coding agent{ANSI_RESET}\n\
153\nFollow these steps to sign in with ChatGPT using device code authorization:\n\
154\n1. Open this link in your browser and sign in to your account\n   {ANSI_BLUE}{verification_url}{ANSI_RESET}\n\
155\n2. Enter this one-time code {ANSI_GRAY}(expires in 15 minutes){ANSI_RESET}\n   {ANSI_BLUE}{code}{ANSI_RESET}\n\
156\n{ANSI_GRAY}Continue only if you started this login in Codex. If a website or another person gave you this code, cancel.{ANSI_RESET}\n",
157    )
158}
159
160fn print_device_code_prompt(verification_url: &str, code: &str) {
161    let prompt = device_code_prompt(verification_url, code);
162    println!("{prompt}");
163}
164
165pub async fn request_device_code(opts: &ServerOptions) -> std::io::Result<DeviceCode> {
166    let base_url = opts.issuer.trim_end_matches('/');
167    // The route selected for the issuer is reused for all device-auth endpoint paths; the endpoint
168    // paths are not resolved separately.
169    let client = create_raw_auth_client(base_url, opts.auth_route_config.as_ref())?;
170    let api_base_url = format!("{base_url}/api/accounts");
171    let uc = request_user_code(&client, &api_base_url, &opts.client_id).await?;
172
173    Ok(DeviceCode {
174        verification_url: format!("{base_url}/codex/device"),
175        user_code: uc.user_code,
176        device_auth_id: uc.device_auth_id,
177        interval: uc.interval,
178    })
179}
180
181pub async fn complete_device_code_login(
182    opts: ServerOptions,
183    device_code: DeviceCode,
184) -> std::io::Result<()> {
185    let base_url = opts.issuer.trim_end_matches('/');
186    let client = create_raw_auth_client(base_url, opts.auth_route_config.as_ref())?;
187    let api_base_url = format!("{base_url}/api/accounts");
188
189    let code_resp = poll_for_token(
190        &client,
191        &api_base_url,
192        &device_code.device_auth_id,
193        &device_code.user_code,
194        device_code.interval,
195    )
196    .await?;
197
198    let pkce = PkceCodes {
199        code_verifier: code_resp.code_verifier,
200        code_challenge: code_resp.code_challenge,
201    };
202    let redirect_uri = format!("{base_url}/deviceauth/callback");
203
204    let tokens = crate::server::exchange_code_for_tokens(
205        base_url,
206        &opts.client_id,
207        &redirect_uri,
208        &pkce,
209        &code_resp.authorization_code,
210        opts.auth_route_config.as_ref(),
211    )
212    .await
213    .map_err(|err| std::io::Error::other(format!("device code exchange failed: {err}")))?;
214
215    if let Err(message) = crate::server::ensure_workspace_allowed(
216        opts.forced_chatgpt_workspace_id.as_deref(),
217        &tokens.id_token,
218    ) {
219        return Err(io::Error::new(io::ErrorKind::PermissionDenied, message));
220    }
221
222    crate::server::persist_tokens_async(
223        &opts.codex_home,
224        /*api_key*/ None,
225        tokens.id_token,
226        tokens.access_token,
227        tokens.refresh_token,
228        opts.cli_auth_credentials_store_mode,
229        opts.auth_keyring_backend_kind,
230    )
231    .await
232}
233
234pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> {
235    let device_code = request_device_code(&opts).await?;
236    print_device_code_prompt(&device_code.verification_url, &device_code.user_code);
237    complete_device_code_login(opts, device_code).await
238}
239
240#[cfg(test)]
241#[path = "device_code_auth_tests.rs"]
242mod tests;