1use std::io::Cursor;
15use std::io::Read;
16use std::io::Write;
17use std::io::{self};
18use std::net::SocketAddr;
19use std::net::TcpStream;
20use std::path::Path;
21use std::path::PathBuf;
22use std::sync::Arc;
23use std::sync::LazyLock;
24use std::thread;
25use std::time::Duration;
26
27use crate::auth::AuthDotJson;
28use crate::auth::AuthKeyringBackendKind;
29use crate::auth::save_auth;
30use crate::default_client::create_raw_auth_client;
31use crate::default_client::originator;
32use crate::outbound_proxy::AuthRouteConfig;
33use crate::pkce::PkceCodes;
34use crate::pkce::generate_pkce;
35use crate::success_page::LoginSuccessPage;
36use crate::success_page::LoginSuccessRedirect;
37use crate::success_page::compose_success_url;
38use crate::success_page::jwt_auth_claims;
39use crate::token_data::TokenData;
40use crate::token_data::parse_chatgpt_jwt_claims;
41use base64::Engine;
42use chrono::Utc;
43use codex_config::types::AuthCredentialsStoreMode;
44use codex_protocol::auth::AuthMode;
45use codex_utils_template::Template;
46use rand::RngCore;
47use serde_json::Value as JsonValue;
48use tiny_http::Header;
49use tiny_http::Request;
50use tiny_http::Response;
51use tiny_http::Server;
52use tiny_http::StatusCode;
53use tracing::error;
54use tracing::info;
55use tracing::warn;
56
57pub(super) const DEFAULT_ISSUER: &str = "https://auth.openai.com";
58const DEFAULT_PORT: u16 = 1455;
59const FALLBACK_PORT: u16 = 1457;
61static LOGIN_ERROR_PAGE_TEMPLATE: LazyLock<Template> = LazyLock::new(|| {
62 Template::parse(include_str!("assets/error.html"))
63 .unwrap_or_else(|err| panic!("login error page template must parse: {err}"))
64});
65
66#[derive(Debug, Clone)]
68pub struct ServerOptions {
69 pub codex_home: PathBuf,
70 pub client_id: String,
71 pub issuer: String,
72 pub port: u16,
73 pub open_browser: bool,
74 pub force_state: Option<String>,
75 pub forced_chatgpt_workspace_id: Option<Vec<String>>,
76 pub codex_streamlined_login: bool,
77 pub login_success_page: LoginSuccessPage,
78 pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
79 pub auth_keyring_backend_kind: AuthKeyringBackendKind,
80 pub auth_route_config: Option<AuthRouteConfig>,
81}
82
83impl ServerOptions {
84 pub fn new(
86 codex_home: PathBuf,
87 client_id: String,
88 forced_chatgpt_workspace_id: Option<Vec<String>>,
89 cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
90 auth_keyring_backend_kind: AuthKeyringBackendKind,
91 auth_route_config: Option<AuthRouteConfig>,
92 ) -> Self {
93 Self {
94 codex_home,
95 client_id,
96 issuer: DEFAULT_ISSUER.to_string(),
97 port: DEFAULT_PORT,
98 open_browser: true,
99 force_state: None,
100 forced_chatgpt_workspace_id,
101 codex_streamlined_login: false,
102 login_success_page: LoginSuccessPage::default(),
103 cli_auth_credentials_store_mode,
104 auth_keyring_backend_kind,
105 auth_route_config,
106 }
107 }
108}
109
110pub struct LoginServer {
112 pub auth_url: String,
113 pub actual_port: u16,
114 server_handle: tokio::task::JoinHandle<io::Result<()>>,
115 shutdown_handle: ShutdownHandle,
116}
117
118impl LoginServer {
119 pub async fn block_until_done(self) -> io::Result<()> {
121 self.server_handle
122 .await
123 .map_err(|err| io::Error::other(format!("login server thread panicked: {err:?}")))?
124 }
125
126 pub fn cancel(&self) {
128 self.shutdown_handle.shutdown();
129 }
130
131 pub fn cancel_handle(&self) -> ShutdownHandle {
133 self.shutdown_handle.clone()
134 }
135}
136
137#[derive(Clone, Debug)]
139pub struct ShutdownHandle {
140 shutdown_notify: Arc<tokio::sync::Notify>,
141}
142
143impl ShutdownHandle {
144 pub fn shutdown(&self) {
146 self.shutdown_notify.notify_one();
147 }
148}
149
150pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
152 let pkce = generate_pkce();
153 let state = opts.force_state.clone().unwrap_or_else(generate_state);
154
155 let server = bind_server(opts.port)?;
156 let actual_port = match server.server_addr().to_ip() {
157 Some(addr) => addr.port(),
158 None => {
159 return Err(io::Error::new(
160 io::ErrorKind::AddrInUse,
161 "Unable to determine the server port",
162 ));
163 }
164 };
165 let server = Arc::new(server);
166
167 let redirect_uri = format!("http://localhost:{actual_port}/auth/callback");
168 let auth_url = build_authorize_url(
169 &opts.issuer,
170 &opts.client_id,
171 &redirect_uri,
172 &pkce,
173 &state,
174 opts.forced_chatgpt_workspace_id.as_deref(),
175 );
176
177 if opts.open_browser {
178 let _ = webbrowser::open(&auth_url);
179 }
180
181 let (tx, mut rx) = tokio::sync::mpsc::channel::<Request>(16);
183 let _server_handle = {
184 let server = server.clone();
185 thread::spawn(move || -> io::Result<()> {
186 while let Ok(request) = server.recv() {
187 match tx.blocking_send(request) {
188 Ok(()) => {}
189 Err(error) => {
190 eprintln!("Failed to send request to channel: {error}");
191 return Err(io::Error::other("Failed to send request to channel"));
192 }
193 }
194 }
195 Ok(())
196 })
197 };
198
199 let shutdown_notify = Arc::new(tokio::sync::Notify::new());
200 let server_handle = {
201 let shutdown_notify = shutdown_notify.clone();
202 let server = server;
203 tokio::spawn(async move {
204 let result = loop {
205 tokio::select! {
206 _ = shutdown_notify.notified() => {
207 break Err(io::Error::other("Login was not completed"));
208 }
209 maybe_req = rx.recv() => {
210 let Some(req) = maybe_req else {
211 break Err(io::Error::other("Login was not completed"));
212 };
213
214 let url_raw = req.url().to_string();
215 let response =
216 process_request(
217 &url_raw,
218 &opts,
219 &redirect_uri,
220 &pkce,
221 actual_port,
222 &state,
223 )
224 .await;
225
226 let exit_result = match response {
227 HandledRequest::Response(response) => {
228 let _ = tokio::task::spawn_blocking(move || req.respond(response)).await;
229 None
230 }
231 HandledRequest::RedirectWithHeader(header) => {
232 let redirect = Response::empty(302).with_header(header);
233 let _ = tokio::task::spawn_blocking(move || req.respond(redirect)).await;
234 None
235 }
236 HandledRequest::ResponseAndExit {
237 headers,
238 body,
239 result,
240 } => {
241 let _ = tokio::task::spawn_blocking(move || {
242 send_response_with_disconnect(
243 req,
244 StatusCode(200),
245 headers,
246 body,
247 )
248 })
249 .await;
250 Some(result)
251 }
252 HandledRequest::RedirectAndExit(header) => {
253 match tokio::task::spawn_blocking(move || {
254 send_response_with_disconnect(
255 req,
256 StatusCode(302),
257 vec![header],
258 Vec::new(),
259 )
260 })
261 .await
262 {
263 Ok(Ok(())) => {}
264 Ok(Err(err)) => {
265 warn!("failed to send hosted login redirect: {err}");
266 }
267 Err(err) => {
268 warn!("hosted login redirect task failed: {err}");
269 }
270 }
271 Some(Ok(()))
272 }
273 };
274
275 if let Some(result) = exit_result {
276 break result;
277 }
278 }
279 }
280 };
281
282 server.unblock();
285 result
286 })
287 };
288
289 Ok(LoginServer {
290 auth_url,
291 actual_port,
292 server_handle,
293 shutdown_handle: ShutdownHandle { shutdown_notify },
294 })
295}
296
297enum HandledRequest {
299 Response(Response<Cursor<Vec<u8>>>),
300 RedirectWithHeader(Header),
301 RedirectAndExit(Header),
302 ResponseAndExit {
303 headers: Vec<Header>,
304 body: Vec<u8>,
305 result: io::Result<()>,
306 },
307}
308
309async fn process_request(
310 url_raw: &str,
311 opts: &ServerOptions,
312 redirect_uri: &str,
313 pkce: &PkceCodes,
314 actual_port: u16,
315 state: &str,
316) -> HandledRequest {
317 let parsed_url = match url::Url::parse(&format!("http://localhost{url_raw}")) {
318 Ok(u) => u,
319 Err(e) => {
320 eprintln!("URL parse error: {e}");
321 return HandledRequest::Response(
322 Response::from_string("Bad Request").with_status_code(400),
323 );
324 }
325 };
326 let path = parsed_url.path().to_string();
327
328 match path.as_str() {
329 "/auth/callback" => {
330 let params: std::collections::HashMap<String, String> =
331 parsed_url.query_pairs().into_owned().collect();
332 let has_code = params.get("code").is_some_and(|code| !code.is_empty());
333 let has_state = params.get("state").is_some_and(|state| !state.is_empty());
334 let has_error = params.get("error").is_some_and(|error| !error.is_empty());
335 let state_valid = params.get("state").map(String::as_str) == Some(state);
336 info!(
337 path = %path,
338 has_code,
339 has_state,
340 has_error,
341 state_valid,
342 "received login callback"
343 );
344 if !state_valid {
345 warn!(
346 path = %path,
347 has_code,
348 has_state,
349 has_error,
350 "login callback state mismatch"
351 );
352 return HandledRequest::Response(
353 Response::from_string("State mismatch").with_status_code(400),
354 );
355 }
356 if let Some(error_code) = params.get("error") {
357 let error_description = params.get("error_description").map(String::as_str);
358 let message = oauth_callback_error_message(error_code, error_description);
359 eprintln!("OAuth callback error: {message}");
360 warn!(
361 error_code,
362 has_error_description = error_description.is_some_and(|s| !s.trim().is_empty()),
363 "oauth callback returned error"
364 );
365 return login_error_response(
366 &message,
367 io::ErrorKind::PermissionDenied,
368 Some(error_code),
369 error_description,
370 );
371 }
372 let code = match params.get("code") {
373 Some(c) if !c.is_empty() => c.clone(),
374 _ => {
375 return login_error_response(
376 "Missing authorization code. Sign-in could not be completed.",
377 io::ErrorKind::InvalidData,
378 Some("missing_authorization_code"),
379 None,
380 );
381 }
382 };
383
384 match exchange_code_for_tokens(
385 &opts.issuer,
386 &opts.client_id,
387 redirect_uri,
388 pkce,
389 &code,
390 opts.auth_route_config.as_ref(),
391 )
392 .await
393 {
394 Ok(tokens) => {
395 if let Err(message) = ensure_workspace_allowed(
396 opts.forced_chatgpt_workspace_id.as_deref(),
397 &tokens.id_token,
398 ) {
399 eprintln!("Workspace restriction error: {message}");
400 return login_error_response(
401 &message,
402 io::ErrorKind::PermissionDenied,
403 Some("workspace_restriction"),
404 None,
405 );
406 }
407 let api_key = obtain_api_key(
409 &opts.issuer,
410 &opts.client_id,
411 &tokens.id_token,
412 opts.auth_route_config.as_ref(),
413 )
414 .await
415 .ok();
416 if let Err(err) = persist_tokens_async(
417 &opts.codex_home,
418 api_key.clone(),
419 tokens.id_token.clone(),
420 tokens.access_token.clone(),
421 tokens.refresh_token.clone(),
422 opts.cli_auth_credentials_store_mode,
423 opts.auth_keyring_backend_kind,
424 )
425 .await
426 {
427 eprintln!("Persist error: {err}");
428 return login_error_response(
429 "Sign-in completed but credentials could not be saved locally.",
430 io::ErrorKind::Other,
431 Some("persist_failed"),
432 Some(&err.to_string()),
433 );
434 }
435
436 let redirect = compose_success_url(
437 actual_port,
438 &opts.issuer,
439 &tokens.id_token,
440 &tokens.access_token,
441 opts.codex_streamlined_login,
442 &opts.login_success_page,
443 );
444 let url = match &redirect {
445 LoginSuccessRedirect::Local(url) | LoginSuccessRedirect::Hosted(url) => url,
446 };
447 match tiny_http::Header::from_bytes(&b"Location"[..], url.as_bytes()) {
448 Ok(header) => match redirect {
449 LoginSuccessRedirect::Local(_) => {
450 HandledRequest::RedirectWithHeader(header)
451 }
452 LoginSuccessRedirect::Hosted(_) => {
453 HandledRequest::RedirectAndExit(header)
454 }
455 },
456 Err(_) => login_error_response(
457 "Sign-in completed but redirecting back to Codex failed.",
458 io::ErrorKind::Other,
459 Some("redirect_failed"),
460 None,
461 ),
462 }
463 }
464 Err(err) => {
465 eprintln!("Token exchange error: {err}");
466 error!("login callback token exchange failed");
467 login_error_response(
468 &format!("Token exchange failed: {err}"),
469 io::ErrorKind::Other,
470 Some("token_exchange_failed"),
471 None,
472 )
473 }
474 }
475 }
476 "/success" => {
477 let use_streamlined_success = parsed_url
478 .query_pairs()
479 .any(|(key, value)| key == "codex_streamlined_login" && value == "true");
480 let body = if use_streamlined_success {
481 include_str!("assets/success.html")
482 } else {
483 include_str!("assets/success_legacy.html")
484 };
485 HandledRequest::ResponseAndExit {
486 headers: match Header::from_bytes(
487 &b"Content-Type"[..],
488 &b"text/html; charset=utf-8"[..],
489 ) {
490 Ok(header) => vec![header],
491 Err(_) => Vec::new(),
492 },
493 body: body.as_bytes().to_vec(),
494 result: Ok(()),
495 }
496 }
497 "/cancel" => HandledRequest::ResponseAndExit {
498 headers: Vec::new(),
499 body: b"Login cancelled".to_vec(),
500 result: Err(io::Error::new(
501 io::ErrorKind::Interrupted,
502 "Login cancelled",
503 )),
504 },
505 _ => HandledRequest::Response(Response::from_string("Not Found").with_status_code(404)),
506 }
507}
508
509fn send_response_with_disconnect(
519 req: Request,
520 status: StatusCode,
521 mut headers: Vec<Header>,
522 body: Vec<u8>,
523) -> io::Result<()> {
524 let mut writer = req.into_writer();
525 let reason = status.default_reason_phrase();
526 write!(writer, "HTTP/1.1 {} {}\r\n", status.0, reason)?;
527 headers.retain(|h| !h.field.equiv("Connection"));
528 if let Ok(close_header) = Header::from_bytes(&b"Connection"[..], &b"close"[..]) {
529 headers.push(close_header);
530 }
531
532 let content_length_value = format!("{}", body.len());
533 if let Ok(content_length_header) =
534 Header::from_bytes(&b"Content-Length"[..], content_length_value.as_bytes())
535 {
536 headers.push(content_length_header);
537 }
538
539 for header in headers {
540 write!(
541 writer,
542 "{}: {}\r\n",
543 header.field.as_str(),
544 header.value.as_str()
545 )?;
546 }
547
548 writer.write_all(b"\r\n")?;
549 writer.write_all(&body)?;
550 writer.flush()
551}
552
553fn build_authorize_url(
554 issuer: &str,
555 client_id: &str,
556 redirect_uri: &str,
557 pkce: &PkceCodes,
558 state: &str,
559 forced_chatgpt_workspace_ids: Option<&[String]>,
560) -> String {
561 let mut query = vec![
562 ("response_type".to_string(), "code".to_string()),
563 ("client_id".to_string(), client_id.to_string()),
564 ("redirect_uri".to_string(), redirect_uri.to_string()),
565 (
566 "scope".to_string(),
567 "openid profile email offline_access api.connectors.read api.connectors.invoke"
568 .to_string(),
569 ),
570 (
571 "code_challenge".to_string(),
572 pkce.code_challenge.to_string(),
573 ),
574 ("code_challenge_method".to_string(), "S256".to_string()),
575 ("id_token_add_organizations".to_string(), "true".to_string()),
576 ("codex_cli_simplified_flow".to_string(), "true".to_string()),
577 ("state".to_string(), state.to_string()),
578 ("originator".to_string(), originator().value),
579 ];
580 if let Some(workspace_ids) = forced_chatgpt_workspace_ids {
581 query.push(("allowed_workspace_id".to_string(), workspace_ids.join(",")));
582 }
583 let qs = query
584 .into_iter()
585 .map(|(k, v)| format!("{k}={}", urlencoding::encode(&v)))
586 .collect::<Vec<_>>()
587 .join("&");
588 format!("{issuer}/oauth/authorize?{qs}")
589}
590
591fn generate_state() -> String {
592 let mut bytes = [0u8; 32];
593 rand::rng().fill_bytes(&mut bytes);
594 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
595}
596
597fn send_cancel_request(port: u16) -> io::Result<()> {
598 let addr: SocketAddr = format!("127.0.0.1:{port}")
599 .parse()
600 .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
601 let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(2))?;
602 stream.set_read_timeout(Some(Duration::from_secs(2)))?;
603 stream.set_write_timeout(Some(Duration::from_secs(2)))?;
604
605 stream.write_all(b"GET /cancel HTTP/1.1\r\n")?;
606 stream.write_all(format!("Host: 127.0.0.1:{port}\r\n").as_bytes())?;
607 stream.write_all(b"Connection: close\r\n\r\n")?;
608
609 let mut buf = [0u8; 64];
610 let _ = stream.read(&mut buf);
611 Ok(())
612}
613
614fn bind_server(port: u16) -> io::Result<Server> {
615 let preferred_bind_address = format!("127.0.0.1:{port}");
616 let fallback_bind_address = format!("127.0.0.1:{FALLBACK_PORT}");
617 let mut bind_address = preferred_bind_address.clone();
618 let mut cancel_attempted = false;
619 let mut attempts = 0;
620 let mut using_fallback_port = false;
621 const MAX_ATTEMPTS: u32 = 10;
622 const RETRY_DELAY: Duration = Duration::from_millis(200);
623
624 loop {
625 match Server::http(&bind_address) {
626 Ok(server) => return Ok(server),
627 Err(err) => {
628 attempts += 1;
629 let is_addr_in_use = err
630 .downcast_ref::<io::Error>()
631 .map(|io_err| io_err.kind() == io::ErrorKind::AddrInUse)
632 .unwrap_or(false);
633
634 if is_addr_in_use {
637 if !cancel_attempted && !using_fallback_port {
638 cancel_attempted = true;
639 if let Err(cancel_err) = send_cancel_request(port) {
640 eprintln!("Failed to cancel previous login server: {cancel_err}");
641 }
642 }
643
644 thread::sleep(RETRY_DELAY);
645
646 if attempts >= MAX_ATTEMPTS {
647 if port == DEFAULT_PORT && !using_fallback_port {
648 warn!(
649 %preferred_bind_address,
650 %fallback_bind_address,
651 "default login callback port is unavailable; falling back to the registered fallback port"
652 );
653 bind_address = fallback_bind_address.clone();
654 attempts = 0;
655 using_fallback_port = true;
656 continue;
657 }
658
659 return Err(io::Error::new(
660 io::ErrorKind::AddrInUse,
661 format!("Port {bind_address} is already in use"),
662 ));
663 }
664
665 continue;
666 }
667
668 return Err(io::Error::other(err));
669 }
670 }
671 }
672}
673
674pub(crate) struct ExchangedTokens {
676 pub id_token: String,
677 pub access_token: String,
678 pub refresh_token: String,
679}
680
681#[derive(Debug, Clone, PartialEq, Eq)]
682struct TokenEndpointErrorDetail {
683 error_code: Option<String>,
684 error_message: Option<String>,
685 display_message: String,
686}
687
688impl std::fmt::Display for TokenEndpointErrorDetail {
689 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
690 self.display_message.fmt(f)
691 }
692}
693
694const REDACTED_URL_VALUE: &str = "<redacted>";
695const SENSITIVE_URL_QUERY_KEYS: &[&str] = &[
696 "access_token",
697 "api_key",
698 "client_secret",
699 "code",
700 "code_verifier",
701 "id_token",
702 "key",
703 "refresh_token",
704 "requested_token",
705 "state",
706 "subject_token",
707 "token",
708];
709
710fn redact_sensitive_query_value(key: &str, value: &str) -> String {
711 if SENSITIVE_URL_QUERY_KEYS
712 .iter()
713 .any(|candidate| candidate.eq_ignore_ascii_case(key))
714 {
715 REDACTED_URL_VALUE.to_string()
716 } else {
717 value.to_string()
718 }
719}
720
721fn redact_sensitive_url_parts(url: &mut url::Url) {
726 let _ = url.set_username("");
727 let _ = url.set_password(None);
728 url.set_fragment(None);
729
730 let query_pairs = url
731 .query_pairs()
732 .map(|(key, value)| {
733 let key = key.into_owned();
734 let value = value.into_owned();
735 (key.clone(), redact_sensitive_query_value(&key, &value))
736 })
737 .collect::<Vec<_>>();
738
739 if query_pairs.is_empty() {
740 url.set_query(None);
741 return;
742 }
743
744 let redacted_query = query_pairs
745 .into_iter()
746 .fold(
747 url::form_urlencoded::Serializer::new(String::new()),
748 |mut serializer, (key, value)| {
749 serializer.append_pair(&key, &value);
750 serializer
751 },
752 )
753 .finish();
754 url.set_query(Some(&redacted_query));
755}
756
757fn redact_sensitive_error_url(
759 mut err: codex_http_client::HttpError,
760) -> codex_http_client::HttpError {
761 if let Some(url) = err.url_mut() {
762 redact_sensitive_url_parts(url);
763 }
764 err
765}
766
767fn sanitize_url_for_logging(url: &str) -> String {
772 match url::Url::parse(url) {
773 Ok(mut url) => {
774 redact_sensitive_url_parts(&mut url);
775 url.to_string()
776 }
777 Err(_) => "<invalid-url>".to_string(),
778 }
779}
780pub(crate) async fn exchange_code_for_tokens(
787 issuer: &str,
788 client_id: &str,
789 redirect_uri: &str,
790 pkce: &PkceCodes,
791 code: &str,
792 auth_route_config: Option<&AuthRouteConfig>,
793) -> io::Result<ExchangedTokens> {
794 #[derive(serde::Deserialize)]
795 struct TokenResponse {
796 id_token: String,
797 access_token: String,
798 refresh_token: String,
799 }
800
801 let client = create_raw_auth_client(issuer.trim_end_matches('/'), auth_route_config)?;
804 let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/'));
805 info!(
806 issuer = %sanitize_url_for_logging(issuer),
807 token_endpoint = %sanitize_url_for_logging(&token_endpoint),
808 redirect_uri = %redirect_uri,
809 "starting oauth token exchange"
810 );
811 let resp = client
812 .post(token_endpoint)
813 .header("Content-Type", "application/x-www-form-urlencoded")
814 .body(format!(
815 "grant_type=authorization_code&code={}&redirect_uri={}&client_id={}&code_verifier={}",
816 urlencoding::encode(code),
817 urlencoding::encode(redirect_uri),
818 urlencoding::encode(client_id),
819 urlencoding::encode(&pkce.code_verifier)
820 ))
821 .send()
822 .await;
823 let resp = match resp {
824 Ok(resp) => resp,
825 Err(error) => {
826 let error = redact_sensitive_error_url(error);
827 error!(
828 is_timeout = error.is_timeout(),
829 is_connect = error.is_connect(),
830 is_request = error.is_request(),
831 error = %error,
832 "oauth token exchange transport failure"
833 );
834 return Err(io::Error::other(error));
835 }
836 };
837
838 let status = resp.status();
839 if !status.is_success() {
840 let body = resp.text().await.map_err(io::Error::other)?;
841 let detail = parse_token_endpoint_error(&body);
842 warn!(
843 %status,
844 error_code = detail.error_code.as_deref().unwrap_or("unknown"),
845 error_message = detail.error_message.as_deref().unwrap_or("unknown"),
846 "oauth token exchange returned non-success status"
847 );
848 return Err(io::Error::other(format!(
849 "token endpoint returned status {status}: {detail}"
850 )));
851 }
852
853 let tokens: TokenResponse = resp.json().await.map_err(io::Error::other)?;
854 info!(%status, "oauth token exchange succeeded");
855 Ok(ExchangedTokens {
856 id_token: tokens.id_token,
857 access_token: tokens.access_token,
858 refresh_token: tokens.refresh_token,
859 })
860}
861
862pub(crate) async fn persist_tokens_async(
864 codex_home: &Path,
865 api_key: Option<String>,
866 id_token: String,
867 access_token: String,
868 refresh_token: String,
869 auth_credentials_store_mode: AuthCredentialsStoreMode,
870 keyring_backend_kind: AuthKeyringBackendKind,
871) -> io::Result<()> {
872 let codex_home = codex_home.to_path_buf();
874 tokio::task::spawn_blocking(move || {
875 let mut tokens = TokenData {
876 id_token: parse_chatgpt_jwt_claims(&id_token).map_err(io::Error::other)?,
877 access_token,
878 refresh_token,
879 account_id: None,
880 };
881 if let Some(acc) = jwt_auth_claims(&id_token)
882 .get("chatgpt_account_id")
883 .and_then(|v| v.as_str())
884 {
885 tokens.account_id = Some(acc.to_string());
886 }
887 let auth = AuthDotJson {
888 auth_mode: Some(AuthMode::Chatgpt),
889 openai_api_key: api_key,
890 tokens: Some(tokens),
891 last_refresh: Some(Utc::now()),
892 agent_identity: None,
893 personal_access_token: None,
894 bedrock_api_key: None,
895 };
896 save_auth(
897 &codex_home,
898 &auth,
899 auth_credentials_store_mode,
900 keyring_backend_kind,
901 )
902 })
903 .await
904 .map_err(|e| io::Error::other(format!("persist task failed: {e}")))?
905}
906
907pub(crate) fn ensure_workspace_allowed(
909 expected: Option<&[String]>,
910 id_token: &str,
911) -> Result<(), String> {
912 let Some(expected) = expected else {
913 return Ok(());
914 };
915
916 let claims = jwt_auth_claims(id_token);
917 let Some(actual) = claims.get("chatgpt_account_id").and_then(JsonValue::as_str) else {
918 return Err("Login is restricted to a specific workspace, but the token did not include an chatgpt_account_id claim.".to_string());
919 };
920
921 ensure_workspace_account_allowed(Some(expected), actual)
922}
923
924pub(crate) fn ensure_workspace_account_allowed(
928 expected: Option<&[String]>,
929 actual: &str,
930) -> Result<(), String> {
931 let Some(expected) = expected else {
932 return Ok(());
933 };
934
935 if expected.iter().any(|workspace_id| workspace_id == actual) {
936 Ok(())
937 } else {
938 Err(format!(
939 "Login is restricted to workspace id(s) {}.",
940 expected.join(", ")
941 ))
942 }
943}
944
945fn login_error_response(
947 message: &str,
948 kind: io::ErrorKind,
949 error_code: Option<&str>,
950 error_description: Option<&str>,
951) -> HandledRequest {
952 let mut headers = Vec::new();
953 if let Ok(header) = Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]) {
954 headers.push(header);
955 }
956 let body = render_login_error_page(message, error_code, error_description);
957 HandledRequest::ResponseAndExit {
958 headers,
959 body,
960 result: Err(io::Error::new(kind, message.to_string())),
961 }
962}
963
964fn is_missing_codex_entitlement_error(error_code: &str, error_description: Option<&str>) -> bool {
966 error_code == "access_denied"
967 && error_description.is_some_and(|description| {
968 description
969 .to_ascii_lowercase()
970 .contains("missing_codex_entitlement")
971 })
972}
973
974fn oauth_callback_error_message(error_code: &str, error_description: Option<&str>) -> String {
976 if is_missing_codex_entitlement_error(error_code, error_description) {
977 return "Codex is not enabled for your workspace. Contact your workspace administrator to request access to Codex.".to_string();
978 }
979
980 if let Some(description) = error_description
981 && !description.trim().is_empty()
982 {
983 return format!("Sign-in failed: {description}");
984 }
985
986 format!("Sign-in failed: {error_code}")
987}
988
989fn parse_token_endpoint_error(body: &str) -> TokenEndpointErrorDetail {
995 let trimmed = body.trim();
996 if trimmed.is_empty() {
997 return TokenEndpointErrorDetail {
998 error_code: None,
999 error_message: None,
1000 display_message: "unknown error".to_string(),
1001 };
1002 }
1003
1004 let parsed = serde_json::from_str::<JsonValue>(trimmed).ok();
1005 if let Some(json) = parsed {
1006 let error_code = json
1007 .get("error")
1008 .and_then(JsonValue::as_str)
1009 .filter(|error_code| !error_code.trim().is_empty())
1010 .map(ToString::to_string)
1011 .or_else(|| {
1012 json.get("error")
1013 .and_then(JsonValue::as_object)
1014 .and_then(|error_obj| error_obj.get("code"))
1015 .and_then(JsonValue::as_str)
1016 .filter(|code| !code.trim().is_empty())
1017 .map(ToString::to_string)
1018 });
1019 if let Some(description) = json.get("error_description").and_then(JsonValue::as_str)
1020 && !description.trim().is_empty()
1021 {
1022 return TokenEndpointErrorDetail {
1023 error_code,
1024 error_message: Some(description.to_string()),
1025 display_message: description.to_string(),
1026 };
1027 }
1028 if let Some(error_obj) = json.get("error")
1029 && let Some(message) = error_obj.get("message").and_then(JsonValue::as_str)
1030 && !message.trim().is_empty()
1031 {
1032 return TokenEndpointErrorDetail {
1033 error_code,
1034 error_message: Some(message.to_string()),
1035 display_message: message.to_string(),
1036 };
1037 }
1038 if let Some(error_code) = error_code {
1039 return TokenEndpointErrorDetail {
1040 display_message: error_code.clone(),
1041 error_code: Some(error_code),
1042 error_message: None,
1043 };
1044 }
1045 }
1046
1047 TokenEndpointErrorDetail {
1052 error_code: None,
1053 error_message: None,
1054 display_message: trimmed.to_string(),
1055 }
1056}
1057
1058fn render_login_error_page(
1060 message: &str,
1061 error_code: Option<&str>,
1062 error_description: Option<&str>,
1063) -> Vec<u8> {
1064 let code = error_code.unwrap_or("unknown_error");
1065 let (title, display_message, display_description, help_text) =
1066 if is_missing_codex_entitlement_error(code, error_description) {
1067 (
1068 "You do not have access to Codex".to_string(),
1069 "This account is not currently authorized to use Codex in this workspace."
1070 .to_string(),
1071 "Contact your workspace administrator to request access to Codex.".to_string(),
1072 "Contact your workspace administrator to get access to Codex, then return to Codex and try again."
1073 .to_string(),
1074 )
1075 } else {
1076 (
1077 "Sign-in could not be completed".to_string(),
1078 message.to_string(),
1079 error_description.unwrap_or(message).to_string(),
1080 "Return to Codex to retry, switch accounts, or contact your workspace admin if access is restricted."
1081 .to_string(),
1082 )
1083 };
1084 LOGIN_ERROR_PAGE_TEMPLATE
1085 .render([
1086 ("error_title", html_escape(&title)),
1087 ("error_message", html_escape(&display_message)),
1088 ("error_code", html_escape(code)),
1089 ("error_description", html_escape(&display_description)),
1090 ("error_help", html_escape(&help_text)),
1091 ])
1092 .unwrap_or_else(|err| panic!("login error page template must render: {err}"))
1093 .into_bytes()
1094}
1095
1096fn html_escape(input: &str) -> String {
1098 let mut escaped = String::with_capacity(input.len());
1099 for ch in input.chars() {
1100 match ch {
1101 '&' => escaped.push_str("&"),
1102 '<' => escaped.push_str("<"),
1103 '>' => escaped.push_str(">"),
1104 '"' => escaped.push_str("""),
1105 '\'' => escaped.push_str("'"),
1106 _ => escaped.push(ch),
1107 }
1108 }
1109 escaped
1110}
1111
1112pub(crate) async fn obtain_api_key(
1114 issuer: &str,
1115 client_id: &str,
1116 id_token: &str,
1117 auth_route_config: Option<&AuthRouteConfig>,
1118) -> io::Result<String> {
1119 #[derive(serde::Deserialize)]
1121 struct ExchangeResp {
1122 access_token: String,
1123 }
1124 let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/'));
1125 let client = create_raw_auth_client(&token_endpoint, auth_route_config)?;
1126 let resp = client
1127 .post(token_endpoint)
1128 .header("Content-Type", "application/x-www-form-urlencoded")
1129 .body(format!(
1130 "grant_type={}&client_id={}&requested_token={}&subject_token={}&subject_token_type={}",
1131 urlencoding::encode("urn:ietf:params:oauth:grant-type:token-exchange"),
1132 urlencoding::encode(client_id),
1133 urlencoding::encode("openai-api-key"),
1134 urlencoding::encode(id_token),
1135 urlencoding::encode("urn:ietf:params:oauth:token-type:id_token")
1136 ))
1137 .send()
1138 .await
1139 .map_err(io::Error::other)?;
1140 if !resp.status().is_success() {
1141 return Err(io::Error::other(format!(
1142 "api key exchange failed with status {}",
1143 resp.status()
1144 )));
1145 }
1146 let body: ExchangeResp = resp.json().await.map_err(io::Error::other)?;
1147 Ok(body.access_token)
1148}
1149#[cfg(test)]
1150mod tests {
1151 use pretty_assertions::assert_eq;
1152
1153 use super::TokenEndpointErrorDetail;
1154 use super::html_escape;
1155 use super::is_missing_codex_entitlement_error;
1156 use super::parse_token_endpoint_error;
1157 use super::redact_sensitive_query_value;
1158 use super::redact_sensitive_url_parts;
1159 use super::render_login_error_page;
1160 use super::sanitize_url_for_logging;
1161
1162 #[test]
1163 fn parse_token_endpoint_error_prefers_error_description() {
1164 let detail = parse_token_endpoint_error(
1165 r#"{"error":"invalid_grant","error_description":"refresh token expired"}"#,
1166 );
1167
1168 assert_eq!(
1169 detail,
1170 TokenEndpointErrorDetail {
1171 error_code: Some("invalid_grant".to_string()),
1172 error_message: Some("refresh token expired".to_string()),
1173 display_message: "refresh token expired".to_string(),
1174 }
1175 );
1176 }
1177
1178 #[test]
1179 fn parse_token_endpoint_error_reads_nested_error_message_and_code() {
1180 let detail = parse_token_endpoint_error(
1181 r#"{"error":{"code":"proxy_auth_required","message":"proxy authentication required"}}"#,
1182 );
1183
1184 assert_eq!(
1185 detail,
1186 TokenEndpointErrorDetail {
1187 error_code: Some("proxy_auth_required".to_string()),
1188 error_message: Some("proxy authentication required".to_string()),
1189 display_message: "proxy authentication required".to_string(),
1190 }
1191 );
1192 }
1193
1194 #[test]
1195 fn parse_token_endpoint_error_falls_back_to_error_code() {
1196 let detail = parse_token_endpoint_error(r#"{"error":"temporarily_unavailable"}"#);
1197
1198 assert_eq!(
1199 detail,
1200 TokenEndpointErrorDetail {
1201 error_code: Some("temporarily_unavailable".to_string()),
1202 error_message: None,
1203 display_message: "temporarily_unavailable".to_string(),
1204 }
1205 );
1206 }
1207
1208 #[test]
1209 fn parse_token_endpoint_error_preserves_plain_text_for_display() {
1210 let detail = parse_token_endpoint_error("service unavailable");
1211
1212 assert_eq!(
1213 detail,
1214 TokenEndpointErrorDetail {
1215 error_code: None,
1216 error_message: None,
1217 display_message: "service unavailable".to_string(),
1218 }
1219 );
1220 }
1221
1222 #[test]
1223 fn redact_sensitive_query_value_only_scrubs_known_keys() {
1224 assert_eq!(
1225 redact_sensitive_query_value("code", "abc123"),
1226 "<redacted>".to_string()
1227 );
1228 assert_eq!(
1229 redact_sensitive_query_value("redirect_uri", "http://localhost:1455/auth/callback"),
1230 "http://localhost:1455/auth/callback".to_string()
1231 );
1232 }
1233
1234 #[test]
1235 fn redact_sensitive_url_parts_preserves_safe_url_shape() {
1236 let mut url = url::Url::parse(
1237 "https://user:pass@auth.openai.com/oauth/token?code=abc123&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback#frag",
1238 )
1239 .expect("valid url");
1240
1241 redact_sensitive_url_parts(&mut url);
1242
1243 assert_eq!(
1244 url.as_str(),
1245 "https://auth.openai.com/oauth/token?code=%3Credacted%3E&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback"
1246 );
1247 }
1248
1249 #[test]
1250 fn sanitize_url_for_logging_redacts_sensitive_issuer_parts() {
1251 let redacted =
1252 sanitize_url_for_logging("https://user:pass@example.com/base?token=abc123&env=prod");
1253
1254 assert_eq!(
1255 redacted,
1256 "https://example.com/base?token=%3Credacted%3E&env=prod".to_string()
1257 );
1258 }
1259
1260 #[test]
1261 fn render_login_error_page_escapes_dynamic_fields() {
1262 let body = String::from_utf8(render_login_error_page(
1263 "<bad>",
1264 Some("code&value"),
1265 Some("\"quoted\""),
1266 ))
1267 .expect("login error page should be utf-8");
1268
1269 assert!(body.contains(&html_escape("Sign-in could not be completed")));
1270 assert!(body.contains("<bad>"));
1271 assert!(body.contains("code&value"));
1272 assert!(body.contains(""quoted""));
1273 }
1274
1275 #[test]
1276 fn render_login_error_page_uses_entitlement_copy() {
1277 let error_description = Some("missing_codex_entitlement");
1278 assert!(is_missing_codex_entitlement_error(
1279 "access_denied",
1280 error_description
1281 ));
1282
1283 let body = String::from_utf8(render_login_error_page(
1284 "access denied",
1285 Some("access_denied"),
1286 error_description,
1287 ))
1288 .expect("login error page should be utf-8");
1289
1290 assert!(body.contains("You do not have access to Codex"));
1291 assert!(body.contains("Contact your workspace administrator"));
1292 assert!(!body.contains("missing_codex_entitlement"));
1293 }
1294}