pub mod jwt;
#[cfg(feature = "oauth")]
pub mod callback;
#[cfg(feature = "oauth")]
pub mod device_code;
#[cfg(feature = "oauth")]
pub mod pkce;
#[cfg(feature = "oauth")]
pub mod token_exchange;
#[cfg(feature = "oauth")]
pub use callback::{
LoopbackBinding, LoopbackHandle, LoopbackOutcome, bind_loopback_callback,
bind_loopback_callback_with_redirect, run_loopback_callback,
};
#[cfg(feature = "oauth")]
pub use device_code::{
DeviceCodeResponse, DevicePollOutcome, poll_device_code, request_device_code,
};
#[cfg(feature = "oauth")]
pub use pkce::{PkceChallenge, PkcePair};
#[cfg(feature = "oauth")]
pub use token_exchange::{
exchange_authorization_code, exchange_authorization_code_with_state, exchange_refresh_token,
};
use meerkat_core::auth::{RefreshError, RefreshFailureObservation};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum OAuthTokenRequestFormat {
#[default]
FormUrlEncoded,
Json,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthEndpoints {
pub client_id: String,
pub authorize_url: String,
pub token_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub device_code_url: Option<String>,
pub redirect_uri: String,
pub scopes: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extra_authorize_params: Vec<(String, String)>,
#[serde(default)]
pub token_request_format: OAuthTokenRequestFormat,
#[serde(default)]
pub include_state_in_token_exchange: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extra_token_params: Vec<(String, String)>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub refresh_scopes: Vec<String>,
#[serde(default)]
pub extra_headers: Vec<(String, String)>,
}
impl OAuthEndpoints {
#[cfg(feature = "oauth")]
pub fn authorize_url_with_pkce(&self, pkce: &PkceChallenge, state: &str) -> String {
let mut query = vec![
("response_type", "code".to_string()),
("client_id", self.client_id.clone()),
("redirect_uri", self.redirect_uri.clone()),
("code_challenge", pkce.code.clone()),
("code_challenge_method", pkce.method.to_string()),
("state", state.to_string()),
];
if !self.scopes.is_empty() {
query.push(("scope", self.scopes.join(" ")));
}
query.extend(
self.extra_authorize_params
.iter()
.map(|(key, value)| (key.as_str(), value.clone())),
);
let qs = query
.iter()
.map(|(k, v)| format!("{k}={}", urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
if self.authorize_url.contains('?') {
format!("{}&{qs}", self.authorize_url)
} else {
format!("{}?{qs}", self.authorize_url)
}
}
}
#[derive(Debug, Clone)]
pub struct OAuthTokenResult {
pub access_token: String,
pub refresh_token: Option<String>,
pub id_token: Option<String>,
pub expires_in_secs: Option<u64>,
pub scope: Option<String>,
}
impl OAuthTokenResult {
pub fn expires_at_from(
&self,
now: chrono::DateTime<chrono::Utc>,
) -> Result<Option<chrono::DateTime<chrono::Utc>>, OAuthError> {
let Some(expires_in_secs) = self.expires_in_secs else {
return Ok(None);
};
let signed_seconds = i64::try_from(expires_in_secs)
.map_err(|_| OAuthError::TokenExpiryOutOfRange { expires_in_secs })?;
let lifetime = chrono::Duration::try_seconds(signed_seconds)
.ok_or(OAuthError::TokenExpiryOutOfRange { expires_in_secs })?;
now.checked_add_signed(lifetime)
.map(Some)
.ok_or(OAuthError::TokenExpiryOutOfRange { expires_in_secs })
}
}
#[derive(Debug, Error)]
pub enum OAuthError {
#[error("user denied authorization")]
UserDenied,
#[error("callback parse error: {0}")]
CallbackParse(String),
#[error("token endpoint error: status={status} body={body}")]
TokenEndpoint { status: u16, body: String },
#[error("token expires_in is out of range: {expires_in_secs}")]
TokenExpiryOutOfRange { expires_in_secs: u64 },
#[error("network error: {0}")]
Network(String),
#[error("timeout")]
Timeout,
#[error("invalid configuration: {0}")]
InvalidConfig(String),
#[error("state mismatch (possible CSRF)")]
StateMismatch,
#[error("device flow still pending (poll again)")]
AuthorizationPending,
#[error("device flow slow down (increase poll interval)")]
SlowDown,
#[error("device flow access denied")]
AccessDenied,
#[error("device flow expired")]
ExpiredToken,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OAuthRefreshPermanence {
Permanent,
Transient,
ReauthRequired,
}
impl OAuthError {
pub fn refresh_permanence(&self) -> OAuthRefreshPermanence {
match self {
OAuthError::TokenEndpoint { status, .. } => match status {
400 | 401 | 403 | 422 => OAuthRefreshPermanence::ReauthRequired,
500..=599 => OAuthRefreshPermanence::Transient,
_ => OAuthRefreshPermanence::Transient,
},
OAuthError::UserDenied | OAuthError::AccessDenied | OAuthError::ExpiredToken => {
OAuthRefreshPermanence::ReauthRequired
}
OAuthError::StateMismatch => OAuthRefreshPermanence::Permanent,
OAuthError::CallbackParse(_)
| OAuthError::TokenExpiryOutOfRange { .. }
| OAuthError::Network(_)
| OAuthError::Timeout
| OAuthError::InvalidConfig(_)
| OAuthError::AuthorizationPending
| OAuthError::SlowDown => OAuthRefreshPermanence::Transient,
}
}
}
#[derive(Debug, Deserialize)]
struct OAuthTokenEndpointErrorBody {
error: Option<String>,
}
pub fn oauth_token_endpoint_error_code(body: &str) -> Option<String> {
let parsed: OAuthTokenEndpointErrorBody = serde_json::from_str(body).ok()?;
parsed.error.map(|value| value.to_ascii_lowercase())
}
pub fn oauth_refresh_observation(error: &OAuthError) -> RefreshFailureObservation {
match error {
OAuthError::TokenEndpoint { status, body } => {
RefreshFailureObservation::oauth_token_endpoint(
*status,
oauth_token_endpoint_error_code(body),
)
}
OAuthError::UserDenied | OAuthError::AccessDenied => {
RefreshFailureObservation::oauth_error_code("access_denied")
}
OAuthError::ExpiredToken => RefreshFailureObservation::oauth_error_code("expired_token"),
OAuthError::StateMismatch => RefreshFailureObservation::local_credential_unusable(),
OAuthError::CallbackParse(_)
| OAuthError::TokenExpiryOutOfRange { .. }
| OAuthError::Network(_)
| OAuthError::Timeout
| OAuthError::InvalidConfig(_) => RefreshFailureObservation::transient(),
OAuthError::AuthorizationPending => {
RefreshFailureObservation::oauth_error_code("authorization_pending")
}
OAuthError::SlowDown => RefreshFailureObservation::oauth_error_code("slow_down"),
}
}
pub fn oauth_refresh_error(error: OAuthError) -> RefreshError {
let message = error.to_string();
let observation = oauth_refresh_observation(&error);
RefreshError::Observed {
message,
observation,
}
}
#[cfg(feature = "oauth")]
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn authorize_url_includes_pkce_and_state() {
let ep = OAuthEndpoints {
client_id: "cid".into(),
authorize_url: "https://example.com/oauth/authorize".into(),
token_url: "https://example.com/oauth/token".into(),
device_code_url: None,
redirect_uri: "http://127.0.0.1:8777/callback".into(),
scopes: vec!["read".into(), "write".into()],
extra_authorize_params: Vec::new(),
token_request_format: OAuthTokenRequestFormat::FormUrlEncoded,
include_state_in_token_exchange: false,
extra_token_params: Vec::new(),
refresh_scopes: Vec::new(),
extra_headers: Vec::new(),
};
let pkce = PkcePair::generate_s256();
let url = ep.authorize_url_with_pkce(&pkce.challenge, "state-abc");
assert!(url.starts_with("https://example.com/oauth/authorize?"));
assert!(url.contains("response_type=code"));
assert!(url.contains("client_id=cid"));
assert!(url.contains(&format!("code_challenge={}", pkce.challenge.code)));
assert!(url.contains("code_challenge_method=S256"));
assert!(url.contains("state=state-abc"));
assert!(url.contains("scope=read%20write"));
assert!(url.contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A8777%2Fcallback"));
}
#[test]
fn authorize_url_preserves_existing_query() {
let ep = OAuthEndpoints {
client_id: "cid".into(),
authorize_url: "https://example.com/authorize?prompt=consent".into(),
token_url: "https://example.com/token".into(),
device_code_url: None,
redirect_uri: "http://localhost/cb".into(),
scopes: vec![],
extra_authorize_params: Vec::new(),
token_request_format: OAuthTokenRequestFormat::FormUrlEncoded,
include_state_in_token_exchange: false,
extra_token_params: Vec::new(),
refresh_scopes: Vec::new(),
extra_headers: Vec::new(),
};
let pkce = PkcePair::generate_s256();
let url = ep.authorize_url_with_pkce(&pkce.challenge, "x");
assert!(url.contains("prompt=consent&response_type=code"));
}
#[test]
fn authorize_url_includes_extra_authorize_params() {
let ep = OAuthEndpoints {
client_id: "cid".into(),
authorize_url: "https://example.com/oauth/authorize".into(),
token_url: "https://example.com/oauth/token".into(),
device_code_url: None,
redirect_uri: "http://localhost:1455/auth/callback".into(),
scopes: vec!["openid".into()],
extra_authorize_params: vec![
("id_token_add_organizations".into(), "true".into()),
("codex_cli_simplified_flow".into(), "true".into()),
("originator".into(), "codex_cli_rs".into()),
],
token_request_format: OAuthTokenRequestFormat::FormUrlEncoded,
include_state_in_token_exchange: false,
extra_token_params: Vec::new(),
refresh_scopes: Vec::new(),
extra_headers: Vec::new(),
};
let pkce = PkcePair::generate_s256();
let url = ep.authorize_url_with_pkce(&pkce.challenge, "state-abc");
assert!(url.contains("id_token_add_organizations=true"));
assert!(url.contains("codex_cli_simplified_flow=true"));
assert!(url.contains("originator=codex_cli_rs"));
}
fn token_result(expires_in_secs: Option<u64>) -> OAuthTokenResult {
OAuthTokenResult {
access_token: "access-token".to_string(),
refresh_token: None,
id_token: None,
expires_in_secs,
scope: None,
}
}
#[test]
fn refresh_permanence_is_structural_not_body_text() {
let bad_request = OAuthError::TokenEndpoint {
status: 400,
body: "this body literally says transient but is permanent".into(),
};
assert_eq!(
bad_request.refresh_permanence(),
OAuthRefreshPermanence::ReauthRequired
);
let unauthorized = OAuthError::TokenEndpoint {
status: 401,
body: String::new(),
};
assert_eq!(
unauthorized.refresh_permanence(),
OAuthRefreshPermanence::ReauthRequired
);
let server_error = OAuthError::TokenEndpoint {
status: 503,
body: r#"{"error":"invalid_grant"}"#.into(),
};
assert_eq!(
server_error.refresh_permanence(),
OAuthRefreshPermanence::Transient
);
assert_eq!(
OAuthError::Network("connection reset".into()).refresh_permanence(),
OAuthRefreshPermanence::Transient
);
assert_eq!(
OAuthError::Timeout.refresh_permanence(),
OAuthRefreshPermanence::Transient
);
assert_eq!(
OAuthError::StateMismatch.refresh_permanence(),
OAuthRefreshPermanence::Permanent
);
assert_eq!(
OAuthError::AccessDenied.refresh_permanence(),
OAuthRefreshPermanence::ReauthRequired
);
}
#[test]
fn token_expiry_rejects_lifetime_that_cannot_fit_signed_duration() {
let result = token_result(Some(u64::MAX));
let err = result
.expires_at_from(chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap())
.expect_err("oversized expires_in must not wrap negative");
assert!(matches!(
err,
OAuthError::TokenExpiryOutOfRange {
expires_in_secs: u64::MAX
}
));
}
#[test]
fn token_expiry_rejects_timestamp_overflow() {
let result = token_result(Some(1));
let err = result
.expires_at_from(chrono::DateTime::<chrono::Utc>::MAX_UTC)
.expect_err("expires_in must not overflow DateTime bounds");
assert!(matches!(
err,
OAuthError::TokenExpiryOutOfRange { expires_in_secs: 1 }
));
}
}