use super::{AuthError, CHATGPT_AUTH_BASE, CHATGPT_CLIENT_ID};
use base64::Engine;
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
use futures::channel::oneshot;
use sha2::{Digest, Sha256};
use std::io::{self, Read, Write};
use std::net::{Ipv4Addr, TcpListener, TcpStream};
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
pub(super) const CALLBACK_PORTS: [u16; 2] = [1455, 1457];
pub(super) const CALLBACK_PATH: &str = "/auth/callback";
pub(super) const SCOPE: &str = "openid profile email offline_access";
const ACCEPT_POLL: Duration = Duration::from_millis(100);
const STREAM_TIMEOUT: Duration = Duration::from_secs(10);
const EXCHANGE_WAIT: Duration = Duration::from_secs(120);
const MAX_REQUEST_BYTES: usize = 16 * 1024;
pub(super) struct Pkce {
pub(super) verifier: String,
pub(super) challenge: String,
}
impl Pkce {
pub(super) fn generate() -> Self {
let verifier = random_token(4);
Self {
challenge: pkce_challenge(&verifier),
verifier,
}
}
}
pub(super) fn pkce_challenge(verifier: &str) -> String {
BASE64_URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
}
pub(super) fn random_token(uuids: usize) -> String {
let bytes: Vec<u8> = (0..uuids)
.flat_map(|_| uuid::Uuid::new_v4().into_bytes())
.collect();
BASE64_URL_SAFE_NO_PAD.encode(bytes)
}
pub(super) fn redirect_uri(port: u16) -> String {
format!("http://127.0.0.1:{port}{CALLBACK_PATH}")
}
pub(super) fn authorize_url(
redirect_uri: &str,
code_challenge: &str,
state: &str,
originator: &str,
) -> String {
let query = url::form_urlencoded::Serializer::new(String::new())
.extend_pairs([
("response_type", "code"),
("client_id", CHATGPT_CLIENT_ID),
("redirect_uri", redirect_uri),
("code_challenge", code_challenge),
("code_challenge_method", "S256"),
("state", state),
("scope", SCOPE),
("id_token_add_organizations", "true"),
("codex_cli_simplified_flow", "true"),
("originator", originator),
])
.finish();
format!("{CHATGPT_AUTH_BASE}/oauth/authorize?{query}")
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum Callback {
Code(String),
Failed(String),
StateMismatch,
Other,
}
pub(super) fn parse_callback(request_line: &str, expected_state: &str) -> Callback {
let mut parts = request_line.split_whitespace();
let (Some("GET"), Some(target)) = (parts.next(), parts.next()) else {
return Callback::Other;
};
let (path, query) = target.split_once('?').unwrap_or((target, ""));
if path != CALLBACK_PATH {
return Callback::Other;
}
let (mut code, mut state, mut error, mut description) = (None, None, None, None);
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
let slot = match key.as_ref() {
"code" => &mut code,
"state" => &mut state,
"error" => &mut error,
"error_description" => &mut description,
_ => continue,
};
*slot = Some(value.into_owned());
}
if state.as_deref() != Some(expected_state) {
return Callback::StateMismatch;
}
if let Some(error) = error {
return Callback::Failed(match description.filter(|text| !text.trim().is_empty()) {
Some(description) => format!("the browser sign-in returned {error} ({description})"),
None => format!("the browser sign-in returned {error}"),
});
}
match code.filter(|code| !code.is_empty()) {
Some(code) => Callback::Code(code),
None => Callback::Failed("the browser sign-in returned no authorization code".into()),
}
}
pub(super) fn bind() -> Result<(TcpListener, u16), AuthError> {
for port in CALLBACK_PORTS {
match TcpListener::bind((Ipv4Addr::LOCALHOST, port)) {
Ok(listener) => return Ok((listener, port)),
Err(error)
if matches!(
error.kind(),
io::ErrorKind::AddrInUse | io::ErrorKind::PermissionDenied
) => {}
Err(error) => return Err(error.into()),
}
}
let [port, fallback] = CALLBACK_PORTS;
Err(io::Error::new(
io::ErrorKind::AddrInUse,
format!("the sign-in callback ports {port} and {fallback} on 127.0.0.1 are in use"),
)
.into())
}
pub(super) struct Received {
pub(super) code: String,
outcome: mpsc::Sender<Result<(), String>>,
}
impl Received {
pub(super) fn finish(self, outcome: Result<(), String>) {
self.outcome.send(outcome).ok();
}
}
pub(super) fn spawn_listener(
listener: TcpListener,
state: String,
deadline: Instant,
) -> Result<oneshot::Receiver<Result<Received, AuthError>>, AuthError> {
listener.set_nonblocking(true)?;
let (sender, receiver) = oneshot::channel();
std::thread::Builder::new()
.name("chatgpt-sign-in".into())
.spawn(move || serve(listener, &state, deadline, sender))?;
Ok(receiver)
}
fn serve(
listener: TcpListener,
state: &str,
deadline: Instant,
sender: oneshot::Sender<Result<Received, AuthError>>,
) {
let result = loop {
if sender.is_canceled() {
return;
}
if Instant::now() >= deadline {
break Err(AuthError::Message(
"timed out waiting for the sign-in in the browser".into(),
));
}
let mut stream = match listener.accept() {
Ok((stream, _)) => stream,
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
std::thread::sleep(ACCEPT_POLL);
continue;
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
Err(error) => break Err(error.into()),
};
let Ok(line) = read_request_line(&mut stream) else {
continue;
};
match parse_callback(&line, state) {
Callback::Other => respond(&mut stream, "404 Not Found", "Not found."),
Callback::StateMismatch => respond(
&mut stream,
"400 Bad Request",
&page(
"Sign-in not recognised",
"This sign-in link is not the one rig is waiting for. Start it again from rig.",
),
),
Callback::Failed(message) => {
respond(
&mut stream,
"200 OK",
&page(
"Sign-in failed",
&format!("Sign-in failed: {message}. Return to rig."),
),
);
break Err(AuthError::Message(message));
}
Callback::Code(code) => {
let (outcome, wait) = mpsc::channel();
if sender.send(Ok(Received { code, outcome })).is_err() {
respond(&mut stream, "200 OK", &cancelled_page());
return;
}
let body = match wait.recv_timeout(EXCHANGE_WAIT) {
Ok(Ok(())) => page(
"Signed in to ChatGPT",
"You can close this tab and return to rig.",
),
Ok(Err(message)) => page(
"Sign-in failed",
&format!("Sign-in failed: {message}. Return to rig."),
),
Err(_) => cancelled_page(),
};
respond(&mut stream, "200 OK", &body);
return;
}
}
};
sender.send(result).ok();
}
fn read_request_line(stream: &mut TcpStream) -> io::Result<String> {
stream.set_nonblocking(false)?;
stream.set_read_timeout(Some(STREAM_TIMEOUT))?;
stream.set_write_timeout(Some(STREAM_TIMEOUT))?;
let mut head = Vec::new();
let mut chunk = [0u8; 1024];
while !head.windows(4).any(|window| window == b"\r\n\r\n") {
if head.len() >= MAX_REQUEST_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"request head too large",
));
}
let read = stream.read(&mut chunk)?;
if read == 0 {
break;
}
head.extend(chunk.iter().take(read));
}
let head = String::from_utf8_lossy(&head);
Ok(head.lines().next().unwrap_or_default().to_owned())
}
fn respond(stream: &mut TcpStream, status: &str, body: &str) {
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\n\
Content-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream.write_all(response.as_bytes()).ok();
stream.flush().ok();
}
fn cancelled_page() -> String {
page(
"Sign-in cancelled",
"rig stopped waiting for this sign-in. Start it again from rig.",
)
}
fn page(title: &str, message: &str) -> String {
format!(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>{title}</title></head>\
<body style=\"font-family: system-ui, sans-serif; margin: 4rem auto; max-width: 32rem\">\
<h1>{title}</h1><p>{}</p></body></html>",
escape_html(message)
)
}
fn escape_html(text: &str) -> String {
let mut escaped = String::with_capacity(text.len());
for character in text.chars() {
match character {
'&' => escaped.push_str("&"),
'<' => escaped.push_str("<"),
'>' => escaped.push_str(">"),
'"' => escaped.push_str("""),
'\'' => escaped.push_str("'"),
other => escaped.push(other),
}
}
escaped
}
pub(super) fn open_browser(url: &str) -> bool {
let mut command = opener(url);
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let Ok(mut child) = command.spawn() else {
return false;
};
std::thread::Builder::new()
.name("browser-opener".into())
.spawn(move || {
child.wait().ok();
})
.ok();
true
}
#[cfg(target_os = "macos")]
fn opener(url: &str) -> Command {
let mut command = Command::new("open");
command.arg(url);
command
}
#[cfg(windows)]
fn opener(url: &str) -> Command {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let mut command = Command::new("cmd");
command
.raw_arg(format!("/c start \"\" \"{url}\""))
.creation_flags(CREATE_NO_WINDOW);
command
}
#[cfg(not(any(target_os = "macos", windows)))]
fn opener(url: &str) -> Command {
let mut command = Command::new("xdg-open");
command.arg(url);
command
}