use std::{
collections::BTreeMap,
io::{IsTerminal, Read, Write},
path::{Path, PathBuf},
time::Duration,
};
use base64::{
Engine,
engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD},
};
use reqwest::Url;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
sync::oneshot,
};
mod callback;
mod timestamp;
use callback::Callback;
pub use timestamp::Timestamp;
use super::storage::{Location, MAX_CREDENTIAL_BYTES, Storage, Tokens};
pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
const AUTH_ROOT: &str = "https://auth.openai.com";
pub const AUTH_URL_VARIABLE: &str = "ARTIFACTIZE_CODEX_AUTH_URL";
pub const AUTH_FILE_VARIABLE: &str = "ARTIFACTIZE_CODEX_AUTH_FILE";
const CALLBACK_PORT: u16 = 1455;
const CALLBACK_PATH: &str = "/auth/callback";
const SCOPE: &str = "openid profile email offline_access";
pub const ORIGINATOR: &str = "artifactize";
#[cfg(test)]
const CLAIM: &str = "https://api.openai.com/auth";
const CREDENTIALS: &str = "codex.json";
const LOCK: &str = "codex";
const PKCE_VERIFIER_BYTES: usize = 32;
const OAUTH_STATE_BYTES: usize = 16;
const REFRESH_MARGIN: Duration = Duration::from_secs(300);
const FILE_MARGIN: Duration = Duration::from_secs(60);
const LOGIN_TIMEOUT: Duration = Duration::from_secs(300);
const AUTH_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
const CALLBACK_READ_TIMEOUT: Duration = Duration::from_secs(5);
const CALLBACK_WRITE_TIMEOUT: Duration = Duration::from_secs(2);
const REVOCATION_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_OAUTH_ERROR_CHARS: usize = 64;
const LOGIN_REQUIRED: &str =
"Codex is not signed in; run `artifactize login codex` or set ARTIFACTIZE_CODEX_AUTH_FILE.";
#[derive(Deserialize, Serialize)]
struct Credentials {
access_token: String,
refresh_token: String,
account_id: crate::types::CodexAccountId,
#[serde(with = "timestamp::seconds")]
expires_at: Timestamp,
#[serde(with = "timestamp::seconds")]
saved_at: Timestamp,
}
#[derive(Debug)]
pub struct TokenError {
pub transient: bool,
pub message: String,
}
impl From<String> for TokenError {
fn from(message: String) -> Self {
Self {
transient: false,
message,
}
}
}
impl From<&str> for TokenError {
fn from(message: &str) -> Self {
message.to_owned().into()
}
}
pub struct Token {
pub access_token: String,
pub account_id: crate::types::CodexAccountId,
}
pub(crate) fn now() -> Result<Timestamp, String> {
Timestamp::now()
}
pub fn auth_root() -> Result<String, String> {
Ok(crate::llm::variable_endpoint(AUTH_URL_VARIABLE)?.unwrap_or_else(|| AUTH_ROOT.into()))
}
pub fn auth_file() -> Option<PathBuf> {
crate::platform::environment::var(AUTH_FILE_VARIABLE)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
}
fn client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.retry(reqwest::retry::never())
.timeout(AUTH_HTTP_TIMEOUT)
.build()
.map_err(|_| "Cannot initialize the Codex sign-in HTTP client.".into())
}
fn claims(token: &str) -> Option<JwtClaims> {
let mut parts = token.split('.');
let (_, payload, _, None) = (parts.next()?, parts.next()?, parts.next()?, parts.next()) else {
return None;
};
let bytes = URL_SAFE_NO_PAD
.decode(payload)
.or_else(|_| URL_SAFE.decode(payload))
.ok()?;
serde_json::from_slice(&bytes).ok()
}
fn account_id(access_token: &str) -> Option<crate::types::CodexAccountId> {
claims(access_token)?.auth.0?.0.chatgpt_account_id.0
}
fn expiry(access_token: &str) -> Option<Timestamp> {
claims(access_token)?.exp.0
}
#[derive(Default, Deserialize)]
struct JwtClaims {
#[serde(default)]
exp: crate::json::Optional<Timestamp>,
#[serde(default, rename = "https://api.openai.com/auth")]
auth: crate::json::Optional<crate::json::Object<AccountClaim>>,
}
#[derive(Default, Deserialize)]
struct AccountClaim {
#[serde(default)]
chatgpt_account_id: crate::json::Optional<crate::types::CodexAccountId>,
}
fn random(bytes: usize) -> Result<Vec<u8>, String> {
let mut value = vec![0; bytes];
getrandom::fill(&mut value).map_err(|_| "Cannot obtain secure randomness.".to_owned())?;
Ok(value)
}
struct Pending {
verifier: String,
state: crate::types::OAuthState,
}
impl Pending {
fn new() -> Result<Self, String> {
let state: String = random(OAUTH_STATE_BYTES)?
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
Ok(Self {
verifier: URL_SAFE_NO_PAD.encode(random(PKCE_VERIFIER_BYTES)?),
state: state.parse().expect("generated OAuth state is valid"),
})
}
fn challenge(&self) -> String {
URL_SAFE_NO_PAD.encode(Sha256::digest(self.verifier.as_bytes()))
}
fn authorize_url(&self, root: &str, redirect_uri: &str) -> Result<Url, String> {
let mut url = Url::parse(&format!("{root}/oauth/authorize"))
.map_err(|_| "Invalid Codex sign-in URL.".to_owned())?;
url.query_pairs_mut().extend_pairs([
("response_type", "code"),
("client_id", CLIENT_ID),
("redirect_uri", redirect_uri),
("scope", SCOPE),
("code_challenge", &self.challenge()),
("code_challenge_method", "S256"),
("state", &self.state),
("id_token_add_organizations", "true"),
("codex_cli_simplified_flow", "true"),
("originator", ORIGINATOR),
]);
Ok(url)
}
fn pasted(&self, input: &str) -> Result<String, String> {
let input = input.trim();
let (code, state) = if let Ok(url) = Url::parse(input) {
let pairs: BTreeMap<_, _> = url.query_pairs().into_owned().collect();
(pairs.get("code").cloned(), pairs.get("state").cloned())
} else if let Some((code, state)) = input.split_once('#') {
(Some(code.to_owned()), Some(state.to_owned()))
} else if input.contains("code=") {
let pairs: BTreeMap<_, _> = url::form_urlencoded::parse(input.as_bytes())
.into_owned()
.collect();
(pairs.get("code").cloned(), pairs.get("state").cloned())
} else {
(Some(input.to_owned()), None)
};
if state.is_some_and(|state| state != self.state) {
return Err("Codex sign-in state mismatch.".into());
}
code.filter(|code| !code.is_empty())
.ok_or_else(|| "The pasted input has no authorization code.".into())
}
fn callback(
&self,
target: &str,
) -> (&'static str, &'static str, Option<Result<String, String>>) {
let Ok(url) = Url::parse(&format!("http://localhost{target}")) else {
return ("400 Bad Request", "Invalid callback.", None);
};
if url.path() != CALLBACK_PATH {
return ("404 Not Found", "Not found.", None);
}
let mut parameters = BTreeMap::new();
for (key, value) in url.query_pairs().into_owned() {
if parameters.insert(key, value).is_some() {
return ("400 Bad Request", "Duplicate callback parameter.", None);
}
}
if parameters.get("state").map(String::as_str) != Some(self.state.as_str()) {
return ("400 Bad Request", "State mismatch.", None);
}
if let Some(error) = parameters.get("error") {
let error: String = error
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '_')
.take(MAX_OAUTH_ERROR_CHARS)
.collect();
return (
"400 Bad Request",
"Codex sign-in was not completed. Return to artifactize.",
Some(Err(format!("Codex sign-in failed ({error})."))),
);
}
match parameters.get("code").filter(|code| !code.is_empty()) {
Some(code) => (
"200 OK",
"Signed in to Codex. Return to artifactize; you can close this page.",
Some(Ok(code.clone())),
),
None => ("400 Bad Request", "Missing authorization code.", None),
}
}
async fn serve(&self, listener: TcpListener) -> Result<String, String> {
const MAX_CALLBACK_HEADER_BYTES: usize = 16 * 1024;
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
let hosts = [
format!("localhost:{port}"),
format!("127.0.0.1:{port}"),
format!("[::1]:{port}"),
];
loop {
let (mut stream, _) = listener.accept().await.map_err(|e| e.to_string())?;
let head = tokio::time::timeout(CALLBACK_READ_TIMEOUT, async {
let mut head = Vec::new();
let mut byte = [0];
while !head.ends_with(b"\r\n\r\n") && head.len() < MAX_CALLBACK_HEADER_BYTES {
if stream.read(&mut byte).await? == 0 {
break;
}
head.push(byte[0]);
}
Ok::<_, std::io::Error>(head)
})
.await;
let Ok(Ok(head)) = head else { continue };
let head = String::from_utf8_lossy(&head);
let line: Vec<_> = head
.lines()
.next()
.unwrap_or("")
.split_whitespace()
.collect();
let host: Vec<_> = head
.lines()
.skip(1)
.filter_map(|line| line.split_once(':'))
.filter(|(name, _)| name.eq_ignore_ascii_case("host"))
.map(|(_, value)| value.trim())
.collect();
let valid = head.ends_with("\r\n\r\n")
&& line.len() == 3
&& line[0] == "GET"
&& matches!(line[2], "HTTP/1.0" | "HTTP/1.1")
&& host.len() == 1
&& hosts.iter().any(|expected| expected == host[0])
&& line[1].starts_with('/')
&& !line[1].starts_with("//");
let (status, body, result) = if valid {
self.callback(line[1])
} else {
("400 Bad Request", "Invalid callback.", None)
};
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = tokio::time::timeout(
CALLBACK_WRITE_TIMEOUT,
stream.write_all(response.as_bytes()),
)
.await;
if let Some(result) = result {
return result;
}
}
}
async fn wait(
&self,
listener: Option<Callback>,
pasted: Option<oneshot::Receiver<String>>,
) -> Result<String, String> {
let callback = async {
match listener {
Some(listener) => listener.wait(self).await,
None => std::future::pending().await,
}
};
let pasted = async {
match pasted {
Some(receiver) => match receiver.await {
Ok(input) => self.pasted(&input),
Err(_) => std::future::pending().await,
},
None => std::future::pending().await,
}
};
tokio::time::timeout(LOGIN_TIMEOUT, async {
tokio::select! {
result = callback => result,
result = pasted => result,
}
})
.await
.map_err(|_| "Codex sign-in timed out; run `artifactize login codex` again.".to_owned())?
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Prompt {
pub url: String,
pub callback: bool,
}
pub async fn login(state: Option<&Path>, repo: Option<&Path>) -> Result<(), String> {
Storage::new(state, repo, Tokens::Codex)?;
auth_root()?;
let terminal = std::io::stdin().is_terminal();
let pasted = if terminal {
let (send, receive) = oneshot::channel();
std::thread::Builder::new()
.name("codex-login-input".into())
.spawn(move || {
for line in std::io::stdin().lines() {
match line {
Ok(line) if line.trim().is_empty() => continue,
Ok(line) => {
let _ = send.send(line);
}
Err(_) => {}
}
break;
}
})
.map_err(|error| format!("Cannot start Codex sign-in input: {error}"))?;
Some(receive)
} else {
None
};
login_with(state, repo, pasted, true, |prompt| {
let mut err = std::io::stderr().lock();
let _ = writeln!(err, "Open this URL to sign in with Codex:\n{}", prompt.url);
if !prompt.callback {
let _ = writeln!(
err,
"Port {CALLBACK_PORT} is in use (another sign-in, such as the Codex CLI or app, may hold it), so the browser cannot return here."
);
}
if terminal {
let _ = writeln!(
err,
"When the browser cannot return here, paste the full URL it was sent to (starting http://localhost:{CALLBACK_PORT}{CALLBACK_PATH}?code=) and press Enter. Find it in the browser's address bar after the failed load, or in DevTools → Network if the page keeps loading."
);
}
})
.await
}
pub async fn login_with(
state: Option<&Path>,
repo: Option<&Path>,
pasted: Option<oneshot::Receiver<String>>,
browser: bool,
show: impl FnOnce(&Prompt),
) -> Result<(), String> {
let storage = Storage::new(state, repo, Tokens::Codex)?;
let root = auth_root()?;
let listener = Callback::bind(CALLBACK_PORT)
.map_err(|error| format!("Cannot bind Codex sign-in callback: {error}"))?;
let redirect_uri = format!("http://localhost:{CALLBACK_PORT}{CALLBACK_PATH}");
sign_in(
&storage,
&root,
listener,
&redirect_uri,
pasted,
browser,
|url, callback| {
show(&Prompt {
url: url.to_string(),
callback,
})
},
)
.await
}
async fn sign_in(
storage: &Storage,
root: &str,
listener: Option<Callback>,
redirect_uri: &str,
pasted: Option<oneshot::Receiver<String>>,
browser: bool,
show: impl FnOnce(&Url, bool),
) -> Result<(), String> {
if listener.is_none() && pasted.is_none() {
return Err(format!(
"Port {CALLBACK_PORT} is in use and there is no terminal to paste the redirect URL into; finish the other sign-in and try again."
));
}
let pending = Pending::new()?;
let url = pending.authorize_url(root, redirect_uri)?;
show(&url, listener.is_some());
if browser {
open_browser(&url).await;
}
let code = pending.wait(listener, pasted).await?;
let tokens = token_request(
root,
&[
("grant_type", "authorization_code"),
("client_id", CLIENT_ID),
("code", &code),
("code_verifier", &pending.verifier),
("redirect_uri", redirect_uri),
],
None,
)
.await
.map_err(|error| error.message)?;
let credentials = credentials(tokens, now()?)?;
let _lock = storage.lock(LOCK).await?;
storage.save(CREDENTIALS, &credentials)
}
async fn open_browser(url: &Url) {
let _ = crate::platform::program::open_desktop(std::ffi::OsStr::new(url.as_str())).await;
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: Option<String>,
refresh_token: Option<String>,
#[serde(
default,
deserialize_with = "timestamp::optional_duration::deserialize"
)]
expires_in: Option<Duration>,
}
fn credentials(tokens: TokenResponse, saved_at: Timestamp) -> Result<Credentials, String> {
let invalid = || "Invalid Codex token response; nothing was saved.".to_owned();
let access_token = tokens.access_token.filter(|token| !token.is_empty());
let refresh_token = tokens.refresh_token.filter(|token| !token.is_empty());
let (Some(access_token), Some(refresh_token), Some(expires_in)) = (
access_token,
refresh_token,
tokens.expires_in.filter(|duration| !duration.is_zero()),
) else {
return Err(invalid());
};
let account_id = account_id(&access_token)
.ok_or("The Codex access token names no ChatGPT account; nothing was saved.")?;
Ok(Credentials {
access_token,
refresh_token,
account_id,
expires_at: saved_at.checked_add(expires_in).ok_or_else(invalid)?,
saved_at,
})
}
fn terminal(code: &str) -> bool {
matches!(
code,
"invalid_grant"
| "refresh_token_expired"
| "refresh_token_reused"
| "refresh_token_invalidated"
)
}
async fn token_request(
root: &str,
form: &[(&str, &str)],
refreshing: Option<&Storage>,
) -> Result<TokenResponse, TokenError> {
let response = client()?
.post(format!("{root}/oauth/token"))
.form(form)
.send()
.await
.map_err(|_| TokenError {
transient: true,
message:
"Codex token request failed; check your connection. The stored sign-in was kept."
.into(),
})?;
let status = response.status();
if !status.is_success() {
let body: Value = response.json().await.unwrap_or_default();
let code = body["error"]
.as_str()
.or_else(|| body["error"]["code"].as_str())
.or_else(|| body["code"].as_str())
.unwrap_or_default();
let transient =
status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error();
let status = status.as_u16();
let message = match refreshing {
Some(storage) if terminal(code) => {
storage.remove(CREDENTIALS)?;
format!(
"The Codex refresh token is no longer valid ({code}); run `artifactize login codex`."
)
}
Some(_) => {
format!("Codex token refresh failed (HTTP {status}); the stored sign-in was kept.")
}
None if code == "invalid_grant" => {
"The Codex authorization code was rejected (invalid_grant); run `artifactize login codex` again."
.into()
}
None => format!("Codex sign-in failed (HTTP {status})."),
};
return Err(TokenError { transient, message });
}
response
.json()
.await
.map_err(|_| "Invalid Codex token response; nothing was saved.".into())
}
pub async fn access_token(state: Option<&Path>, repo: Option<&Path>) -> Result<Token, TokenError> {
if let Some(path) = auth_file() {
return Ok(read_auth_file(&path)?);
}
stored_token(&Storage::new(state, repo, Tokens::Codex)?, &auth_root()?).await
}
async fn stored_token(storage: &Storage, root: &str) -> Result<Token, TokenError> {
stored_token_at(storage, root, now).await
}
async fn stored_token_at(
storage: &Storage,
root: &str,
clock: impl Fn() -> Result<Timestamp, String>,
) -> Result<Token, TokenError> {
let _lock = storage.lock(LOCK).await?;
let stored = storage
.read::<Credentials>(CREDENTIALS)?
.ok_or(LOGIN_REQUIRED)?;
if stored.expires_at > clock()?.saturating_add(REFRESH_MARGIN) {
return Ok(Token {
access_token: stored.access_token,
account_id: stored.account_id,
});
}
let tokens = token_request(
root,
&[
("grant_type", "refresh_token"),
("refresh_token", &stored.refresh_token),
("client_id", CLIENT_ID),
],
Some(storage),
)
.await?;
let refreshed = credentials(tokens, clock()?)?;
if refreshed.account_id != stored.account_id {
return Err(
"The refreshed Codex token is for another ChatGPT account; run `artifactize login codex`."
.into(),
);
}
storage.save(CREDENTIALS, &refreshed)?;
Ok(Token {
access_token: refreshed.access_token,
account_id: refreshed.account_id,
})
}
#[derive(Default)]
struct AuthFile {
tokens: crate::json::Optional<crate::json::Object<AuthFileTokens>>,
}
#[derive(Default)]
struct AuthFileTokens {
access_token: crate::json::Optional<String>,
account_id: crate::json::Optional<String>,
}
impl<'de> Deserialize<'de> for AuthFile {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct AuthVisitor;
impl<'de> serde::de::Visitor<'de> for AuthVisitor {
type Value = AuthFile;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("an auth-file object")
}
fn visit_map<M: serde::de::MapAccess<'de>>(
self,
mut map: M,
) -> Result<Self::Value, M::Error> {
let mut auth = AuthFile::default();
while let Some(field) = map.next_key::<String>()? {
if field == "tokens" {
auth.tokens = map.next_value()?;
} else {
map.next_value::<crate::json::Ignored>()?;
}
}
Ok(auth)
}
}
deserializer.deserialize_map(AuthVisitor)
}
}
impl<'de> Deserialize<'de> for AuthFileTokens {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct TokensVisitor;
impl<'de> serde::de::Visitor<'de> for TokensVisitor {
type Value = AuthFileTokens;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("an auth-file token object")
}
fn visit_map<M: serde::de::MapAccess<'de>>(
self,
mut map: M,
) -> Result<Self::Value, M::Error> {
let mut tokens = AuthFileTokens::default();
while let Some(field) = map.next_key::<String>()? {
match field.as_str() {
"access_token" => tokens.access_token = map.next_value()?,
"account_id" => tokens.account_id = map.next_value()?,
_ => {
map.next_value::<crate::json::Ignored>()?;
}
}
}
Ok(tokens)
}
}
deserializer.deserialize_map(TokensVisitor)
}
}
#[derive(Debug)]
enum AuthFileError {
Expired(String),
Unreadable(String),
}
impl std::fmt::Display for AuthFileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Expired(message) | Self::Unreadable(message) => write!(f, "{message}"),
}
}
}
impl From<AuthFileError> for String {
fn from(error: AuthFileError) -> Self {
error.to_string()
}
}
impl From<AuthFileError> for TokenError {
fn from(error: AuthFileError) -> Self {
error.to_string().into()
}
}
fn read_auth_file(path: &Path) -> Result<Token, AuthFileError> {
read_auth_file_at(path, now().unwrap_or(Timestamp::from_seconds(u64::MAX)))
}
fn read_auth_file_at(path: &Path, now: Timestamp) -> Result<Token, AuthFileError> {
let name = crate::platform::path_text(path);
let unreadable = |reason: &str| {
AuthFileError::Unreadable(format!(
"{AUTH_FILE_VARIABLE}: cannot read {name}: {reason}."
))
};
let file =
crate::platform::open_regular(path).map_err(|error| unreadable(&error.to_string()))?;
if !file.metadata().is_ok_and(|metadata| metadata.is_file()) {
return Err(unreadable("it is not a regular file"));
}
let mut data = Vec::new();
file.take(MAX_CREDENTIAL_BYTES as u64 + 1)
.read_to_end(&mut data)
.map_err(|error| unreadable(&error.to_string()))?;
if data.len() > MAX_CREDENTIAL_BYTES {
return Err(unreadable("it is larger than 1 MiB"));
}
let auth: crate::json::Optional<crate::json::Object<AuthFile>> =
serde_json::from_slice(&data).map_err(|_| unreadable("it is not a Codex auth file"))?;
let tokens = auth
.0
.and_then(|crate::json::Object(auth)| auth.tokens.0)
.map(|crate::json::Object(tokens)| tokens);
let access_token = tokens
.as_ref()
.and_then(|tokens| tokens.access_token.0.as_deref())
.filter(|token| !token.is_empty())
.ok_or_else(|| unreadable("it holds no ChatGPT sign-in tokens"))?;
if expiry(access_token).is_some_and(|exp| exp <= now.saturating_add(FILE_MARGIN)) {
return Err(AuthFileError::Expired(format!(
"The Codex access token in {name} has expired; sign in with Codex again (for example `codex login`). artifactize never refreshes that file."
)));
}
let account_id = tokens
.as_ref()
.and_then(|tokens| tokens.account_id.0.as_deref())
.filter(|id| !id.is_empty())
.and_then(|id| id.parse().ok())
.or_else(|| account_id(access_token))
.ok_or_else(|| unreadable("its token names no ChatGPT account"))?;
Ok(Token {
access_token: access_token.to_owned(),
account_id,
})
}
#[derive(Debug)]
pub enum FileExpiry {
Usable { expires_at: Option<Timestamp> },
Expired,
}
#[derive(Debug)]
pub enum StoredExpiry {
Usable { expires_at: Timestamp },
Expired { expires_at: Timestamp },
}
#[derive(Debug)]
pub enum Status {
Absent,
Refused { reason: String },
File { path: PathBuf, expiry: FileExpiry },
Stored { expiry: StoredExpiry },
}
impl Serialize for Status {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
let (source, file, expires_at, expired, refused) = match self {
Self::Absent => ("none", None, None, false, None),
Self::Refused { reason } => ("none", None, None, false, Some(reason)),
Self::File {
path,
expiry: FileExpiry::Usable { expires_at },
} => ("file", Some(path), *expires_at, false, None),
Self::File {
path,
expiry: FileExpiry::Expired,
} => ("file", Some(path), None, true, None),
Self::Stored {
expiry: StoredExpiry::Usable { expires_at },
} => ("stored", None, Some(*expires_at), false, None),
Self::Stored {
expiry: StoredExpiry::Expired { expires_at },
} => ("stored", None, Some(*expires_at), true, None),
};
let mut wire = serializer.serialize_struct(
"Status",
3 + usize::from(file.is_some()) + usize::from(refused.is_some()),
)?;
wire.serialize_field("source", source)?;
if let Some(file) = file {
wire.serialize_field("authFile", &crate::platform::path_text(file))?;
}
wire.serialize_field("expiresAt", &expires_at)?;
wire.serialize_field("expired", &expired)?;
if let Some(reason) = refused {
wire.serialize_field("refused", reason)?;
}
wire.end()
}
}
pub fn status(state: Option<&Path>, repo: Option<&Path>) -> Result<Status, String> {
let now = now()?;
if let Some(path) = auth_file() {
let expiry = match read_auth_file(&path) {
Ok(token) => FileExpiry::Usable {
expires_at: expiry(&token.access_token),
},
Err(AuthFileError::Expired(_)) => FileExpiry::Expired,
Err(error) => return Err(error.into()),
};
return Ok(Status::File { path, expiry });
}
let location = Location::find(state, repo)?;
if let Some(refusal) = location.refusal(Tokens::Codex) {
return match crate::platform::entry_exists(&location.directory.join(CREDENTIALS)) {
Ok(false) => Ok(Status::Refused { reason: refusal }),
_ => Err(refusal),
};
}
let stored = Storage::inspect(state, repo, Tokens::Codex)?.read::<Credentials>(CREDENTIALS)?;
Ok(match stored {
None => Status::Absent,
Some(stored) => Status::Stored {
expiry: if stored.expires_at <= now {
StoredExpiry::Expired {
expires_at: stored.expires_at,
}
} else {
StoredExpiry::Usable {
expires_at: stored.expires_at,
}
},
},
})
}
pub async fn logout(state: Option<&Path>, repo: Option<&Path>) -> Result<bool, String> {
sign_out(&Storage::new(state, repo, Tokens::Codex)?, &auth_root()?).await
}
async fn sign_out(storage: &Storage, root: &str) -> Result<bool, String> {
let _lock = storage.lock(LOCK).await?;
let Some(stored) = storage.read::<Credentials>(CREDENTIALS)? else {
return Ok(true);
};
let revoked = revoke(root, &stored.refresh_token).await;
storage.remove(CREDENTIALS)?;
Ok(revoked)
}
async fn revoke(root: &str, refresh_token: &str) -> bool {
let Ok(client) = client() else {
return false;
};
client
.post(format!("{root}/oauth/revoke"))
.timeout(REVOCATION_TIMEOUT)
.json(
&json!({"token":refresh_token,"token_type_hint":"refresh_token","client_id":CLIENT_ID}),
)
.send()
.await
.is_ok_and(|response| response.status().is_success())
}
#[cfg(test)]
mod callback_tests;
#[cfg(test)]
mod redirect_tests;
#[cfg(test)]
mod tests;