codex_login/
device_code_auth.rs1use 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
62async 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
99async 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 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 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;