use crate::ProserpinaError;
use base64::Engine;
use sha2::{Digest, Sha256};
pub struct OAuthTokens {
pub access: String,
pub refresh: String,
pub expires_ms: u64, }
pub fn generate_pkce() -> (String, String) {
use rand::RngCore;
let mut bytes = [0u8; 32];
rand::rng().fill_bytes(&mut bytes);
let verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
let mut hasher = Sha256::new();
hasher.update(verifier.as_bytes());
let digest = hasher.finalize();
let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
(verifier, challenge)
}
pub fn build_authorize_url(
client_id: &str,
authorize_url: &str,
redirect_uri: &str,
scope: &str,
challenge: &str,
state: &str,
) -> String {
format!(
"{authorize_url}?response_type=code&client_id={client_id}&redirect_uri={redirect_uri}\
&scope={scope}&code_challenge={challenge}&code_challenge_method=S256&state={state}\
&codex_cli_simplified_flow=true&originator=proserpina"
)
}
pub fn exchange_code(
token_url: &str,
client_id: &str,
code: &str,
verifier: &str,
redirect_uri: &str,
) -> Result<OAuthTokens, ProserpinaError> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| ProserpinaError::agent_failure("oauth", format!("runtime: {e}")))?;
runtime.block_on(async {
let client = reqwest::Client::new();
let params = [
("grant_type", "authorization_code"),
("client_id", client_id),
("code", code),
("code_verifier", verifier),
("redirect_uri", redirect_uri),
];
let resp = client
.post(token_url)
.form(¶ms)
.send()
.await
.map_err(|e| ProserpinaError::agent_failure("oauth", format!("exchange: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(ProserpinaError::agent_failure(
"oauth",
format!("token exchange HTTP {status}: {body}"),
));
}
let json: serde_json::Value = resp
.json()
.await
.map_err(|e| ProserpinaError::agent_failure("oauth", format!("parse: {e}")))?;
let access = json
.get("access_token")
.and_then(|v| v.as_str())
.ok_or_else(|| ProserpinaError::agent_failure("oauth", "no access_token"))?;
let refresh = json
.get("refresh_token")
.and_then(|v| v.as_str())
.ok_or_else(|| ProserpinaError::agent_failure("oauth", "no refresh_token"))?;
let expires_in = json
.get("expires_in")
.and_then(|v| v.as_u64())
.unwrap_or(3600);
let expires_ms = now_ms() + expires_in * 1000;
Ok(OAuthTokens {
access: access.to_owned(),
refresh: refresh.to_owned(),
expires_ms,
})
})
}
pub fn refresh_token(
token_url: &str,
client_id: &str,
refresh_token: &str,
) -> Result<OAuthTokens, ProserpinaError> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| ProserpinaError::agent_failure("oauth", format!("runtime: {e}")))?;
runtime.block_on(async {
let client = reqwest::Client::new();
let params = [
("grant_type", "refresh_token"),
("client_id", client_id),
("refresh_token", refresh_token),
];
let resp = client
.post(token_url)
.form(¶ms)
.send()
.await
.map_err(|e| ProserpinaError::agent_failure("oauth", format!("refresh: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(ProserpinaError::agent_failure(
"oauth",
format!("token refresh HTTP {status}: {body}"),
));
}
let json: serde_json::Value = resp
.json()
.await
.map_err(|e| ProserpinaError::agent_failure("oauth", format!("parse: {e}")))?;
let access = json
.get("access_token")
.and_then(|v| v.as_str())
.ok_or_else(|| ProserpinaError::agent_failure("oauth", "no access_token"))?;
let refresh = json
.get("refresh_token")
.and_then(|v| v.as_str())
.ok_or_else(|| ProserpinaError::agent_failure("oauth", "no refresh_token"))?;
let expires_in = json
.get("expires_in")
.and_then(|v| v.as_u64())
.unwrap_or(3600);
let expires_ms = now_ms() + expires_in * 1000;
Ok(OAuthTokens {
access: access.to_owned(),
refresh: refresh.to_owned(),
expires_ms,
})
})
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
pub fn generate_state() -> String {
use rand::RngCore;
let mut bytes = [0u8; 16];
rand::rng().fill_bytes(&mut bytes);
hex::encode(bytes)
}
pub const CALLBACK_PORT: u16 = 1455;
pub const REDIRECT_URI: &str = "http://localhost:1455/auth/callback";
pub fn wait_for_callback(expected_state: &str) -> Result<String, ProserpinaError> {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::time::Duration;
let listener = TcpListener::bind(format!("127.0.0.1:{CALLBACK_PORT}"))
.map_err(|e| ProserpinaError::agent_failure("oauth", format!("callback bind: {e}")))?;
listener
.set_nonblocking(true)
.map_err(|e| ProserpinaError::agent_failure("oauth", format!("nonblocking: {e}")))?;
let start = std::time::Instant::now();
let timeout = Duration::from_secs(120);
loop {
if start.elapsed() > timeout {
return Err(ProserpinaError::agent_failure(
"oauth",
"callback timed out (120s)",
));
}
match listener.accept() {
Ok((mut stream, _)) => {
let mut buf = [0u8; 4096];
let n = stream.read(&mut buf).unwrap_or(0);
let request = String::from_utf8_lossy(&buf[..n]);
let request_line = request.lines().next().unwrap_or("");
let url = request_line.split_whitespace().nth(1).unwrap_or("");
let (code, error_html) = parse_callback(url, expected_state);
let html = if let Some(ref err) = error_html {
err.clone()
} else {
SUCCESS_HTML.to_owned()
};
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
html.len(),
html
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
if let Some(code) = code {
return Ok(code);
}
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(100));
}
Err(e) => {
return Err(ProserpinaError::agent_failure(
"oauth",
format!("callback accept: {e}"),
));
}
}
}
}
fn parse_callback(url: &str, expected_state: &str) -> (Option<String>, Option<String>) {
let query = url.split('?').nth(1).unwrap_or("");
let params: std::collections::HashMap<&str, &str> = query
.split('&')
.filter_map(|kv| kv.split_once('='))
.collect();
if let Some(err) = params.get("error") {
return (
None,
Some(format!(
"<html><body><h2>Authentication failed: {}</h2><p>You can close this window.</p></body></html>",
err
)),
);
}
let state = params.get("state").copied().unwrap_or("");
if state != expected_state {
return (
None,
Some(
"<html><body><h2>State mismatch — possible CSRF attack.</h2></body></html>"
.to_owned(),
),
);
}
let code = params.get("code").map(|c| c.to_string());
let has_code = code.is_some();
(
code,
if has_code {
None
} else {
Some("<html><body><h2>Missing authorization code.</h2></body></html>".to_owned())
},
)
}
const SUCCESS_HTML: &str = "<html><body><h2>✓ Proserpina authenticated successfully!</h2><p>You can close this window.</p></body></html>";
pub fn run_oauth_flow(
client_id: &str,
authorize_url: &str,
token_url: &str,
scope: &str,
) -> Result<OAuthTokens, ProserpinaError> {
let (verifier, challenge) = generate_pkce();
let state = generate_state();
let url = build_authorize_url(
client_id,
authorize_url,
REDIRECT_URI,
scope,
&challenge,
&state,
);
let _ = webbrowser::open(&url);
let code = wait_for_callback(&state)?;
exchange_code(token_url, client_id, &code, &verifier, REDIRECT_URI)
}