Skip to main content

codex_login/
server.rs

1//! Local OAuth callback server for CLI login.
2//!
3//! This module runs the short-lived localhost server used by interactive sign-in.
4//!
5//! The callback flow has two competing responsibilities:
6//!
7//! - preserve enough backend and transport detail for developers, sysadmins, and support
8//!   engineers to diagnose failed sign-ins
9//! - avoid persisting secrets or sensitive URL/query data into normal application logs
10//!
11//! This module therefore keeps the user-facing error path and the structured-log path separate.
12//! Returned `io::Error` values still carry the detail needed by CLI/browser callers, while
13//! structured logs only emit explicitly reviewed fields plus redacted URL/error values.
14use 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;
59// Keep in sync with the Codex CLI Hydra redirect URI allow-list.
60const 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/// Options for launching the local login callback server.
67#[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    /// Creates a server configuration with the default issuer and port.
85    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
110/// Handle for a running login callback server.
111pub 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    /// Waits for the login callback loop to finish.
120    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    /// Requests shutdown of the callback server.
127    pub fn cancel(&self) {
128        self.shutdown_handle.shutdown();
129    }
130
131    /// Returns a cloneable cancel handle for the running server.
132    pub fn cancel_handle(&self) -> ShutdownHandle {
133        self.shutdown_handle.clone()
134    }
135}
136
137/// Handle used to signal the login server loop to exit.
138#[derive(Clone, Debug)]
139pub struct ShutdownHandle {
140    shutdown_notify: Arc<tokio::sync::Notify>,
141}
142
143impl ShutdownHandle {
144    /// Signals the login loop to terminate.
145    pub fn shutdown(&self) {
146        self.shutdown_notify.notify_one();
147    }
148}
149
150/// Starts a local callback server and returns the browser auth URL.
151pub 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    // Map blocking reads from server.recv() to an async channel.
182    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            // Ensure that the server is unblocked so the thread dedicated to
283            // running `server.recv()` in a loop exits cleanly.
284            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
297/// Internal callback handling outcome.
298enum 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                        /*error_description*/ 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                            /*error_description*/ None,
405                        );
406                    }
407                    // Obtain API key via token-exchange and persist
408                    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                            /*error_description*/ 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                        /*error_description*/ 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
509/// tiny_http filters `Connection` headers out of `Response` objects, so using
510/// `req.respond` never informs the client (or the library) that a keep-alive
511/// socket should be closed. That leaves the per-connection worker parked in a
512/// loop waiting for more requests, which in turn causes the next login attempt
513/// to hang on the old connection. This helper bypasses tiny_http’s response
514/// machinery: it extracts the raw writer, prints the HTTP response manually,
515/// and always appends `Connection: close`, ensuring the socket is closed from
516/// the server side. Ideally, tiny_http would provide an API to control
517/// server-side connection persistence, but it does not.
518fn 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 the address is in use, there may be another instance of the login server
635                // running. Attempt to cancel it and retry before falling back.
636                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
674/// Tokens returned by the OAuth authorization-code exchange.
675pub(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
721/// Redacts URL components that commonly carry auth secrets while preserving the host/path shape.
722///
723/// This keeps developer-facing logs useful for debugging transport failures without persisting
724/// tokens, callback codes, fragments, or embedded credentials.
725fn 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
757/// Redacts any URL attached to an HTTP transport error before it is logged or returned.
758fn 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
767/// Sanitizes a free-form URL string for structured logging.
768///
769/// This is used for caller-supplied issuer values, which may contain credentials or query
770/// parameters on non-default deployments.
771fn 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}
780/// Exchanges an authorization code for tokens.
781///
782/// The returned error remains suitable for user-facing CLI/browser surfaces, so backend-provided
783/// non-JSON error text is preserved there. Structured logging stays narrower: it logs reviewed
784/// fields from parsed token responses and redacted transport errors, but does not log the final
785/// callback-layer `%err` string.
786pub(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    // The route selected for the issuer is reused for token exchange; the token endpoint path is
802    // not resolved separately.
803    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
862/// Persists exchanged credentials using the configured local auth store.
863pub(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    // Reuse existing synchronous logic but run it off the async runtime.
873    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
907/// Validates the ID token against an optional workspace restriction.
908pub(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
924/// Validates an already known ChatGPT account ID against an optional workspace restriction.
925///
926/// PAT login calls this directly because `/whoami` supplies the account ID without an ID token.
927pub(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
945/// Builds a terminal callback response for login failures.
946fn 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
964/// Returns true when the OAuth callback represents a missing Codex entitlement.
965fn 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
974/// Converts OAuth callback errors into a user-facing message.
975fn 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
989/// Extracts token endpoint error detail for both structured logging and caller-visible errors.
990///
991/// Parsed JSON fields are safe to log individually. If the response is not JSON, the raw body is
992/// preserved only for the returned error path so the CLI/browser can still surface the backend
993/// detail, while the structured log path continues to use the explicitly parsed safe fields above.
994fn 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    // Preserve non-JSON token-endpoint bodies for the returned error so CLI/browser flows still
1048    // surface the backend detail users and admins need, but keep that text out of structured logs
1049    // by only logging explicitly parsed fields above and avoiding `%err` logging at the callback
1050    // layer.
1051    TokenEndpointErrorDetail {
1052        error_code: None,
1053        error_message: None,
1054        display_message: trimmed.to_string(),
1055    }
1056}
1057
1058/// Renders the branded error page used by callback failures.
1059fn 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
1096/// Escapes error strings before inserting them into HTML.
1097fn 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("&amp;"),
1102            '<' => escaped.push_str("&lt;"),
1103            '>' => escaped.push_str("&gt;"),
1104            '"' => escaped.push_str("&quot;"),
1105            '\'' => escaped.push_str("&#39;"),
1106            _ => escaped.push(ch),
1107        }
1108    }
1109    escaped
1110}
1111
1112/// Exchanges an authenticated ID token for an API-key style access token.
1113pub(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    // Token exchange for an API key access token
1120    #[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("&lt;bad&gt;"));
1271        assert!(body.contains("code&amp;value"));
1272        assert!(body.contains("&quot;quoted&quot;"));
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}