mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
## What changed Include a `refresh_reason` diagnostic in ChatGPT OAuth token refresh requests to distinguish `proactive_expiry`, `scheduled`, `unauthorized_recovery`, and `forced` refreshes. Omit the field for gateway refresh requests. ## Testing Extend refresh tests to check the reported reasons, including scheduled refreshes when access token expiry is unavailable. GitOrigin-RevId: b0294a61ffc3089a66e7b132d4f45661a9e18d38
3160 lines
117 KiB
Rust
3160 lines
117 KiB
Rust
mod workspace_routing;
|
||
|
||
use chrono::Utc;
|
||
use http::StatusCode;
|
||
use serde::Deserialize;
|
||
#[cfg(test)]
|
||
use serial_test::serial;
|
||
use std::env;
|
||
use std::fmt::Debug;
|
||
use std::future::Future;
|
||
use std::path::Path;
|
||
use std::path::PathBuf;
|
||
use std::pin::Pin;
|
||
use std::sync::Arc;
|
||
use std::sync::Mutex;
|
||
use std::sync::OnceLock;
|
||
use std::sync::RwLock;
|
||
use std::sync::Weak;
|
||
use std::sync::atomic::AtomicU64;
|
||
use std::sync::atomic::Ordering;
|
||
use std::time::Duration;
|
||
use std::time::Instant;
|
||
use tokio::sync::Semaphore;
|
||
use tokio::sync::watch;
|
||
use tracing::instrument;
|
||
|
||
use codex_agent_identity::ChatGptEnvironment;
|
||
use codex_protocol::auth::AuthMode;
|
||
use codex_protocol::config_types::ForcedLoginMethod;
|
||
use codex_protocol::config_types::ModelProviderAuthInfo;
|
||
|
||
use super::access_token::CodexAccessToken;
|
||
use super::access_token::classify_codex_access_token;
|
||
use super::agent_identity::ManagedChatGptAgentIdentityBinding;
|
||
use super::agent_identity::agent_identity_authapi_base_url;
|
||
use super::agent_identity::classify_bootstrap_error;
|
||
use super::agent_identity::record_matches_managed_chatgpt_binding;
|
||
use super::agent_identity::record_needs_task_registration;
|
||
use super::agent_identity::register_managed_chatgpt_agent_identity;
|
||
use super::agent_identity::require_agent_identity_authapi_base_url;
|
||
use super::agent_identity::verified_record_from_jwt;
|
||
use super::change_state::AuthChangeState;
|
||
use super::change_state::same_owner;
|
||
use super::external_bearer::BearerTokenRefresher;
|
||
use super::revoke::revoke_auth_tokens;
|
||
use super::workload_identity::WorkloadIdentityExternalAuth;
|
||
use super::workload_identity::WorkloadIdentitySessionError;
|
||
use crate::auth::AuthHeaders;
|
||
pub use crate::auth::agent_identity::AgentIdentityAuth;
|
||
pub use crate::auth::agent_identity::AgentIdentityAuthError;
|
||
pub use crate::auth::bedrock_access_keys::BedrockAccessKeysAuth;
|
||
pub use crate::auth::bedrock_api_key::BedrockApiKeyAuth;
|
||
pub use crate::auth::personal_access_token::PersonalAccessTokenAuth;
|
||
pub use crate::auth::storage::AgentIdentityAuthRecord;
|
||
pub use crate::auth::storage::AgentIdentityStorage;
|
||
pub use crate::auth::storage::AuthDotJson;
|
||
pub use crate::auth::storage::AuthKeyringBackendKind;
|
||
use crate::auth::storage::AuthStorageBackend;
|
||
use crate::auth::storage::create_auth_storage;
|
||
use crate::default_client::create_client;
|
||
use crate::default_client::create_default_auth_client;
|
||
use crate::oauth::ErrorBodyLimit;
|
||
use crate::oauth::OAuthClient;
|
||
use crate::oauth::OAuthError;
|
||
use crate::oauth::RefreshReason;
|
||
use crate::oauth::RefreshTokenGrant;
|
||
use crate::oauth::TokenEncoding;
|
||
use crate::oauth::TokenEndpoint;
|
||
use crate::oauth::TokenErrorDetail;
|
||
use crate::outbound_proxy::AuthRouteConfig;
|
||
use crate::token_data::TokenData;
|
||
use crate::token_data::parse_chatgpt_account_user_id;
|
||
use crate::token_data::parse_chatgpt_jwt_claims;
|
||
use crate::token_data::parse_jwt_expiration;
|
||
use codex_config::ManagedAuthPolicy;
|
||
use codex_config::types::AuthCredentialsStoreMode;
|
||
use codex_http_client::HttpClient;
|
||
use codex_http_client::HttpClientFactory;
|
||
use codex_http_client::OutboundProxyPolicy;
|
||
use codex_protocol::account::PlanType as AccountPlanType;
|
||
use codex_protocol::auth::PlanType as InternalPlanType;
|
||
use codex_protocol::auth::RefreshTokenFailedError;
|
||
use codex_protocol::auth::RefreshTokenFailedReason;
|
||
use codex_protocol::protocol::SessionSource;
|
||
use thiserror::Error;
|
||
pub use workspace_routing::WorkspaceRouting;
|
||
pub use workspace_routing::WorkspaceRoutingRequest;
|
||
pub use workspace_routing::WorkspaceRoutingResolver;
|
||
pub use workspace_routing::WorkspaceRoutingSession;
|
||
|
||
/// Authentication mechanism used by the current user.
|
||
#[derive(Debug, Clone)]
|
||
pub enum CodexAuth {
|
||
ApiKey(ApiKeyAuth),
|
||
Chatgpt(ChatgptAuth),
|
||
ChatgptAuthTokens(ChatgptAuthTokens),
|
||
Headers(AuthHeaders),
|
||
AgentIdentity(AgentIdentityAuth),
|
||
PersonalAccessToken(PersonalAccessTokenAuth),
|
||
BedrockApiKey(BedrockApiKeyAuth),
|
||
BedrockAccessKeys(BedrockAccessKeysAuth),
|
||
}
|
||
|
||
/// Policy for resolving Agent Identity auth from a broader Codex auth snapshot.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum AgentIdentityAuthPolicy {
|
||
/// Use Agent Identity auth only when the current auth is already Agent Identity.
|
||
JwtOnly,
|
||
/// Allow managed ChatGPT auth to register or reuse Agent Identity auth.
|
||
ChatGptAuth,
|
||
}
|
||
|
||
const AGENT_IDENTITY_BOOTSTRAP_FAILURE_COOLDOWN: Duration = Duration::from_secs(60 * 60);
|
||
|
||
#[derive(Debug)]
|
||
struct CachedAgentIdentityBootstrapFailure {
|
||
account_id: String,
|
||
authapi_base_url: String,
|
||
retry_at: Instant,
|
||
error: AgentIdentityAuthError,
|
||
}
|
||
|
||
#[derive(Debug, Default)]
|
||
struct AgentIdentityBootstrapCooldown {
|
||
failure: Option<CachedAgentIdentityBootstrapFailure>,
|
||
}
|
||
|
||
impl AgentIdentityBootstrapCooldown {
|
||
fn error_for(
|
||
&mut self,
|
||
account_id: &str,
|
||
authapi_base_url: &str,
|
||
now: Instant,
|
||
) -> Option<AgentIdentityAuthError> {
|
||
let error = self
|
||
.failure
|
||
.as_ref()
|
||
.filter(|failure| {
|
||
failure.account_id == account_id
|
||
&& failure.authapi_base_url == authapi_base_url
|
||
&& failure.retry_at > now
|
||
})
|
||
.map(|failure| failure.error.clone());
|
||
if error.is_none() {
|
||
self.clear();
|
||
}
|
||
error
|
||
}
|
||
|
||
fn record_failure(
|
||
&mut self,
|
||
account_id: String,
|
||
authapi_base_url: String,
|
||
error: AgentIdentityAuthError,
|
||
now: Instant,
|
||
) {
|
||
self.failure = Some(CachedAgentIdentityBootstrapFailure {
|
||
account_id,
|
||
authapi_base_url,
|
||
retry_at: now + AGENT_IDENTITY_BOOTSTRAP_FAILURE_COOLDOWN,
|
||
error,
|
||
});
|
||
}
|
||
|
||
fn clear(&mut self) {
|
||
self.failure = None;
|
||
}
|
||
}
|
||
|
||
impl PartialEq for CodexAuth {
|
||
fn eq(&self, other: &Self) -> bool {
|
||
match (self, other) {
|
||
(Self::Headers(a), Self::Headers(b)) => a == b,
|
||
(Self::PersonalAccessToken(a), Self::PersonalAccessToken(b)) => a == b,
|
||
(Self::BedrockApiKey(a), Self::BedrockApiKey(b)) => a == b,
|
||
(Self::BedrockAccessKeys(a), Self::BedrockAccessKeys(b)) => a == b,
|
||
_ => self.api_auth_mode() == other.api_auth_mode(),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct ApiKeyAuth {
|
||
api_key: String,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct ChatgptAuth {
|
||
state: ChatgptAuthState,
|
||
storage: Arc<dyn AuthStorageBackend>,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct ChatgptAuthTokens {
|
||
state: ChatgptAuthState,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct ChatgptAuthState {
|
||
auth_dot_json: Arc<Mutex<Option<AuthDotJson>>>,
|
||
client: HttpClient,
|
||
}
|
||
|
||
const TOKEN_REFRESH_INTERVAL: i64 = 8;
|
||
const CHATGPT_ACCESS_TOKEN_REFRESH_WINDOW_MINUTES: i64 = 5;
|
||
|
||
const REFRESH_TOKEN_EXPIRED_MESSAGE: &str = "Your access token could not be refreshed because your refresh token has expired. Please log out and sign in again.";
|
||
const REFRESH_TOKEN_REUSED_MESSAGE: &str = "Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.";
|
||
const REFRESH_TOKEN_INVALIDATED_MESSAGE: &str = "Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again.";
|
||
const REFRESH_TOKEN_UNKNOWN_MESSAGE: &str =
|
||
"Your access token could not be refreshed. Please log out and sign in again.";
|
||
const REFRESH_TOKEN_ACCOUNT_MISMATCH_MESSAGE: &str = "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again.";
|
||
const REFRESH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
|
||
pub(super) const REVOKE_TOKEN_URL: &str = "https://auth.openai.com/oauth/revoke";
|
||
pub const REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REFRESH_TOKEN_URL_OVERRIDE";
|
||
pub const REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REVOKE_TOKEN_URL_OVERRIDE";
|
||
pub const CLIENT_ID_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_CLIENT_ID";
|
||
static NEXT_DUMMY_AUTH_ID: AtomicU64 = AtomicU64::new(1);
|
||
|
||
#[derive(Debug, Error)]
|
||
pub enum RefreshTokenError {
|
||
#[error("{0}")]
|
||
Permanent(#[from] RefreshTokenFailedError),
|
||
#[error(transparent)]
|
||
Transient(#[from] std::io::Error),
|
||
/// Denied by application policy; neither retry nor cache as a credential failure.
|
||
#[error(transparent)]
|
||
Policy(#[from] codex_http_client::NetworkPolicyDenied),
|
||
}
|
||
|
||
/// Error returned when constructing an [`AuthManager`] from resolved configuration.
|
||
#[derive(Debug, Error)]
|
||
#[error(transparent)]
|
||
pub struct AuthManagerInitializationError(AuthManagerInitializationErrorSource);
|
||
|
||
#[derive(Debug, Error)]
|
||
enum AuthManagerInitializationErrorSource {
|
||
#[error(transparent)]
|
||
WorkloadIdentityConfiguration(WorkloadIdentitySessionError),
|
||
#[error(transparent)]
|
||
InitialAuth(RefreshTokenError),
|
||
}
|
||
|
||
impl From<WorkloadIdentitySessionError> for AuthManagerInitializationError {
|
||
fn from(error: WorkloadIdentitySessionError) -> Self {
|
||
Self(AuthManagerInitializationErrorSource::WorkloadIdentityConfiguration(error))
|
||
}
|
||
}
|
||
|
||
impl From<RefreshTokenError> for AuthManagerInitializationError {
|
||
fn from(error: RefreshTokenError) -> Self {
|
||
Self(AuthManagerInitializationErrorSource::InitialAuth(error))
|
||
}
|
||
}
|
||
|
||
impl From<AuthManagerInitializationError> for std::io::Error {
|
||
fn from(error: AuthManagerInitializationError) -> Self {
|
||
Self::other(error)
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum ExternalAuthRefreshReason {
|
||
Unauthorized,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct ExternalAuthRefreshContext {
|
||
pub reason: ExternalAuthRefreshReason,
|
||
pub previous_account_id: Option<String>,
|
||
}
|
||
|
||
/// Pluggable auth provider used by `AuthManager` for externally managed auth flows.
|
||
///
|
||
/// Implementations own the current auth value and any source-specific refresh mechanism.
|
||
pub trait ExternalAuth: Send + Sync {
|
||
/// Returns the provider's current auth value.
|
||
fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth>;
|
||
|
||
/// Refreshes auth and makes the returned value current for future `resolve()` calls.
|
||
fn refresh(&self, context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth>;
|
||
|
||
/// Maps a provider error into the retry policy used by external-auth reload and recovery.
|
||
fn classify_error(&self, error: std::io::Error) -> RefreshTokenError {
|
||
RefreshTokenError::Transient(error)
|
||
}
|
||
}
|
||
|
||
pub type ExternalAuthFuture<'a, T> = Pin<Box<dyn Future<Output = std::io::Result<T>> + Send + 'a>>;
|
||
|
||
fn permanent_external_auth_error(message: impl Into<String>) -> RefreshTokenError {
|
||
RefreshTokenError::Permanent(RefreshTokenFailedError::new(
|
||
RefreshTokenFailedReason::Other,
|
||
message,
|
||
))
|
||
}
|
||
|
||
impl RefreshTokenError {
|
||
pub fn failed_reason(&self) -> Option<RefreshTokenFailedReason> {
|
||
match self {
|
||
Self::Permanent(error) => Some(error.reason),
|
||
Self::Transient(_) | Self::Policy(_) => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<codex_http_client::HttpError> for RefreshTokenError {
|
||
fn from(error: codex_http_client::HttpError) -> Self {
|
||
match error {
|
||
codex_http_client::HttpError::Policy(denied) => Self::Policy(denied),
|
||
error => Self::Transient(std::io::Error::other(error)),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<RefreshTokenError> for std::io::Error {
|
||
fn from(err: RefreshTokenError) -> Self {
|
||
match err {
|
||
RefreshTokenError::Permanent(failed) => std::io::Error::other(failed),
|
||
RefreshTokenError::Transient(inner) => inner,
|
||
RefreshTokenError::Policy(error) => {
|
||
std::io::Error::new(std::io::ErrorKind::PermissionDenied, error)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
impl CodexAuth {
|
||
async fn from_auth_dot_json(
|
||
codex_home: &Path,
|
||
auth_dot_json: AuthDotJson,
|
||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||
chatgpt_base_url: Option<&str>,
|
||
keyring_backend_kind: AuthKeyringBackendKind,
|
||
agent_identity_authapi_base_url: Option<&str>,
|
||
auth_route_config: &AuthRouteConfig,
|
||
) -> std::io::Result<Self> {
|
||
let auth_mode = auth_dot_json.resolved_mode();
|
||
if auth_mode == AuthMode::ApiKey {
|
||
let Some(api_key) = auth_dot_json.openai_api_key.as_deref() else {
|
||
return Err(std::io::Error::other("API key auth is missing a key."));
|
||
};
|
||
return Ok(Self::from_api_key(api_key));
|
||
}
|
||
if auth_mode == AuthMode::AgentIdentity {
|
||
let Some(agent_identity) = auth_dot_json.agent_identity.clone() else {
|
||
return Err(std::io::Error::other(
|
||
"agent identity auth is missing agent identity auth material.",
|
||
));
|
||
};
|
||
let base_url = chatgpt_base_url
|
||
.unwrap_or(ChatGptEnvironment::default().chatgpt_base_url())
|
||
.trim_end_matches('/')
|
||
.to_string();
|
||
let agent_identity_authapi_base_url =
|
||
require_agent_identity_authapi_base_url(agent_identity_authapi_base_url)?;
|
||
match agent_identity {
|
||
AgentIdentityStorage::Jwt(jwt) => {
|
||
let auth = AgentIdentityAuth::from_jwt(
|
||
&jwt,
|
||
&base_url,
|
||
agent_identity_authapi_base_url,
|
||
auth_route_config,
|
||
)
|
||
.await?;
|
||
return Ok(Self::AgentIdentity(auth));
|
||
}
|
||
AgentIdentityStorage::Record(record) => {
|
||
let auth = AgentIdentityAuth::from_record(
|
||
record,
|
||
agent_identity_authapi_base_url,
|
||
auth_route_config,
|
||
)
|
||
.await?;
|
||
return Ok(Self::AgentIdentity(auth));
|
||
}
|
||
}
|
||
}
|
||
if auth_mode == AuthMode::PersonalAccessToken {
|
||
let Some(personal_access_token) = auth_dot_json.personal_access_token.as_deref() else {
|
||
return Err(std::io::Error::other(
|
||
"personal access token auth is missing a personal access token.",
|
||
));
|
||
};
|
||
return Self::from_personal_access_token(personal_access_token, auth_route_config)
|
||
.await;
|
||
}
|
||
if auth_mode == AuthMode::BedrockApiKey {
|
||
let Some(auth) = auth_dot_json.bedrock_api_key else {
|
||
return Err(std::io::Error::other(
|
||
"Bedrock API key auth is missing a Bedrock API key.",
|
||
));
|
||
};
|
||
return Ok(Self::BedrockApiKey(auth));
|
||
}
|
||
if auth_mode == AuthMode::BedrockAccessKeys {
|
||
let Some(auth) = auth_dot_json.bedrock_access_keys else {
|
||
return Err(std::io::Error::other(
|
||
"Bedrock access keys auth is missing AWS access keys.",
|
||
));
|
||
};
|
||
return Ok(Self::BedrockAccessKeys(auth));
|
||
}
|
||
if auth_mode == AuthMode::Headers {
|
||
return Err(std::io::Error::other(
|
||
"externally provided auth cannot be loaded from auth storage.",
|
||
));
|
||
}
|
||
|
||
let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode);
|
||
let client = create_default_auth_client(&refresh_token_endpoint(), auth_route_config)?;
|
||
let state = ChatgptAuthState {
|
||
auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))),
|
||
client,
|
||
};
|
||
|
||
match auth_mode {
|
||
AuthMode::Chatgpt => {
|
||
let storage = create_auth_storage(
|
||
codex_home.to_path_buf(),
|
||
storage_mode,
|
||
keyring_backend_kind,
|
||
);
|
||
Ok(Self::Chatgpt(ChatgptAuth { state, storage }))
|
||
}
|
||
AuthMode::ChatgptAuthTokens => Ok(Self::ChatgptAuthTokens(ChatgptAuthTokens { state })),
|
||
AuthMode::ApiKey => unreachable!("api key mode is handled above"),
|
||
AuthMode::Headers => {
|
||
unreachable!("externally provided auth is never loaded from auth storage")
|
||
}
|
||
AuthMode::AgentIdentity => unreachable!("agent identity mode is handled above"),
|
||
AuthMode::PersonalAccessToken => {
|
||
unreachable!("personal access token mode is handled above")
|
||
}
|
||
AuthMode::BedrockApiKey => unreachable!("bedrock api key mode is handled above"),
|
||
AuthMode::BedrockAccessKeys => {
|
||
unreachable!("bedrock access keys mode is handled above")
|
||
}
|
||
}
|
||
}
|
||
|
||
pub async fn from_auth_storage(
|
||
codex_home: &Path,
|
||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||
chatgpt_base_url: Option<&str>,
|
||
keyring_backend_kind: AuthKeyringBackendKind,
|
||
auth_route_config: &AuthRouteConfig,
|
||
) -> std::io::Result<Option<Self>> {
|
||
let agent_identity_authapi_base_url =
|
||
agent_identity_authapi_base_url(chatgpt_base_url).ok();
|
||
load_auth(
|
||
codex_home,
|
||
/*enable_codex_api_key_env*/ false,
|
||
auth_credentials_store_mode,
|
||
/*allowed_login_methods*/ None,
|
||
/*forced_chatgpt_workspace_id*/ None,
|
||
chatgpt_base_url,
|
||
keyring_backend_kind,
|
||
agent_identity_authapi_base_url.as_deref(),
|
||
auth_route_config,
|
||
)
|
||
.await
|
||
}
|
||
|
||
pub async fn from_agent_identity_jwt(
|
||
jwt: &str,
|
||
chatgpt_base_url: Option<&str>,
|
||
auth_route_config: &AuthRouteConfig,
|
||
) -> std::io::Result<Self> {
|
||
let agent_identity_authapi_base_url = agent_identity_authapi_base_url(chatgpt_base_url)?;
|
||
Self::from_agent_identity_jwt_with_authapi_base_url(
|
||
jwt,
|
||
chatgpt_base_url,
|
||
&agent_identity_authapi_base_url,
|
||
auth_route_config,
|
||
)
|
||
.await
|
||
}
|
||
|
||
async fn from_agent_identity_jwt_with_authapi_base_url(
|
||
jwt: &str,
|
||
chatgpt_base_url: Option<&str>,
|
||
agent_identity_authapi_base_url: &str,
|
||
auth_route_config: &AuthRouteConfig,
|
||
) -> std::io::Result<Self> {
|
||
let base_url = chatgpt_base_url
|
||
.unwrap_or(ChatGptEnvironment::default().chatgpt_base_url())
|
||
.trim_end_matches('/')
|
||
.to_string();
|
||
Ok(Self::AgentIdentity(
|
||
AgentIdentityAuth::from_jwt(
|
||
jwt,
|
||
&base_url,
|
||
agent_identity_authapi_base_url,
|
||
auth_route_config,
|
||
)
|
||
.await?,
|
||
))
|
||
}
|
||
|
||
pub async fn from_personal_access_token(
|
||
access_token: &str,
|
||
auth_route_config: &AuthRouteConfig,
|
||
) -> std::io::Result<Self> {
|
||
Ok(Self::PersonalAccessToken(
|
||
PersonalAccessTokenAuth::load(access_token, auth_route_config).await?,
|
||
))
|
||
}
|
||
|
||
/// Returns the effective backend auth mode.
|
||
///
|
||
/// Externally managed ChatGPT tokens are normalized to [`AuthMode::Chatgpt`].
|
||
pub fn auth_mode(&self) -> AuthMode {
|
||
match self {
|
||
Self::ApiKey(_) => AuthMode::ApiKey,
|
||
Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => AuthMode::Chatgpt,
|
||
Self::Headers(_) => AuthMode::Headers,
|
||
Self::AgentIdentity(_) => AuthMode::AgentIdentity,
|
||
Self::PersonalAccessToken(_) => AuthMode::PersonalAccessToken,
|
||
Self::BedrockApiKey(_) => AuthMode::BedrockApiKey,
|
||
Self::BedrockAccessKeys(_) => AuthMode::BedrockAccessKeys,
|
||
}
|
||
}
|
||
|
||
/// Returns the precise kind of credentials backing this authentication.
|
||
pub fn api_auth_mode(&self) -> AuthMode {
|
||
match self {
|
||
Self::ApiKey(_) => AuthMode::ApiKey,
|
||
Self::Chatgpt(_) => AuthMode::Chatgpt,
|
||
Self::ChatgptAuthTokens(_) => AuthMode::ChatgptAuthTokens,
|
||
Self::Headers(_) => AuthMode::Headers,
|
||
Self::AgentIdentity(_) => AuthMode::AgentIdentity,
|
||
Self::PersonalAccessToken(_) => AuthMode::PersonalAccessToken,
|
||
Self::BedrockApiKey(_) => AuthMode::BedrockApiKey,
|
||
Self::BedrockAccessKeys(_) => AuthMode::BedrockAccessKeys,
|
||
}
|
||
}
|
||
|
||
pub fn is_api_key_auth(&self) -> bool {
|
||
self.auth_mode() == AuthMode::ApiKey
|
||
}
|
||
|
||
pub fn is_personal_access_token_auth(&self) -> bool {
|
||
self.auth_mode() == AuthMode::PersonalAccessToken
|
||
}
|
||
|
||
pub fn is_chatgpt_auth(&self) -> bool {
|
||
self.api_auth_mode().has_chatgpt_account()
|
||
}
|
||
|
||
pub fn uses_codex_backend(&self) -> bool {
|
||
self.api_auth_mode().uses_codex_backend()
|
||
}
|
||
|
||
pub fn is_external_chatgpt_tokens(&self) -> bool {
|
||
matches!(self, Self::ChatgptAuthTokens(_))
|
||
}
|
||
|
||
fn supports_unauthorized_recovery(&self) -> bool {
|
||
matches!(
|
||
self,
|
||
Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) | Self::Headers(_)
|
||
)
|
||
}
|
||
|
||
/// Returns `None` if `auth_mode() != AuthMode::ApiKey`.
|
||
pub fn api_key(&self) -> Option<&str> {
|
||
match self {
|
||
Self::ApiKey(auth) => Some(auth.api_key.as_str()),
|
||
Self::Chatgpt(_)
|
||
| Self::ChatgptAuthTokens(_)
|
||
| Self::Headers(_)
|
||
| Self::AgentIdentity(_)
|
||
| Self::PersonalAccessToken(_)
|
||
| Self::BedrockApiKey(_)
|
||
| Self::BedrockAccessKeys(_) => None,
|
||
}
|
||
}
|
||
|
||
/// Returns `Err` if token-backed ChatGPT auth is unavailable.
|
||
pub fn get_token_data(&self) -> Result<TokenData, std::io::Error> {
|
||
let auth_dot_json: Option<AuthDotJson> = self.get_current_auth_json();
|
||
match auth_dot_json {
|
||
Some(AuthDotJson {
|
||
tokens: Some(tokens),
|
||
last_refresh: Some(_),
|
||
..
|
||
}) => Ok(tokens),
|
||
_ => Err(std::io::Error::other("Token data is not available.")),
|
||
}
|
||
}
|
||
|
||
/// Returns the token string used for bearer authentication.
|
||
pub fn get_token(&self) -> Result<String, std::io::Error> {
|
||
match self {
|
||
Self::ApiKey(auth) => Ok(auth.api_key.clone()),
|
||
Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => {
|
||
let access_token = self.get_token_data()?.access_token;
|
||
Ok(access_token)
|
||
}
|
||
Self::AgentIdentity(_) => Err(std::io::Error::other(
|
||
"agent identity auth does not expose a bearer token",
|
||
)),
|
||
Self::Headers(_) => Err(std::io::Error::other(
|
||
"header auth does not expose a bearer token",
|
||
)),
|
||
Self::PersonalAccessToken(auth) => Ok(auth.access_token().to_string()),
|
||
Self::BedrockApiKey(_) | Self::BedrockAccessKeys(_) => Err(std::io::Error::other(
|
||
"Bedrock API key auth does not expose a Codex bearer token",
|
||
)),
|
||
}
|
||
}
|
||
|
||
/// Returns `None` if Codex backend auth does not expose an account id.
|
||
pub fn get_account_id(&self) -> Option<String> {
|
||
match self {
|
||
Self::Headers(headers) => headers
|
||
.headers()
|
||
.get("chatgpt-account-id")
|
||
.and_then(|value| value.to_str().ok())
|
||
.filter(|account_id| !account_id.is_empty() && account_id.trim() == *account_id)
|
||
.map(ToOwned::to_owned),
|
||
Self::AgentIdentity(auth) => Some(auth.account_id().to_string()),
|
||
Self::PersonalAccessToken(auth) => Some(auth.account_id().to_string()),
|
||
_ => self.get_current_token_data().and_then(|t| t.account_id),
|
||
}
|
||
}
|
||
|
||
/// Returns false if Codex backend auth omits the FedRAMP claim.
|
||
pub fn is_fedramp_account(&self) -> bool {
|
||
match self {
|
||
Self::Headers(_) => false,
|
||
Self::AgentIdentity(auth) => auth.is_fedramp_account(),
|
||
Self::PersonalAccessToken(auth) => auth.is_fedramp_account(),
|
||
_ => self
|
||
.get_current_token_data()
|
||
.is_some_and(|t| t.id_token.is_fedramp_account()),
|
||
}
|
||
}
|
||
|
||
/// Returns `None` if Codex backend auth does not expose an account email.
|
||
pub fn get_account_email(&self) -> Option<String> {
|
||
match self {
|
||
Self::Headers(_) => None,
|
||
Self::AgentIdentity(auth) => auth.email().map(str::to_string),
|
||
Self::PersonalAccessToken(auth) => auth.email().map(str::to_string),
|
||
_ => self.get_current_token_data().and_then(|t| t.id_token.email),
|
||
}
|
||
}
|
||
|
||
/// Returns `None` if Codex backend auth does not expose a ChatGPT user id.
|
||
pub fn get_chatgpt_user_id(&self) -> Option<String> {
|
||
match self {
|
||
Self::Headers(_) => None,
|
||
Self::AgentIdentity(auth) => Some(auth.chatgpt_user_id().to_string()),
|
||
Self::PersonalAccessToken(auth) => Some(auth.chatgpt_user_id().to_string()),
|
||
_ => self
|
||
.get_current_token_data()
|
||
.and_then(|t| t.id_token.chatgpt_user_id),
|
||
}
|
||
}
|
||
|
||
/// Returns the access token's opaque account-user identity only when its workspace
|
||
/// matches the selected account. Missing claims never fall back to a user id.
|
||
/// Unlike `get_chatgpt_user_id`, this identifies one workspace membership, so keys
|
||
/// are not shared across a person's workspaces. Workload-identity exchange claims
|
||
/// describe an agent identity and cannot select a human verification credential.
|
||
/// This is local identity selection, not access-token or proof validation.
|
||
pub fn get_chatgpt_account_user_id(&self) -> Option<String> {
|
||
let tokens = self.get_current_token_data()?;
|
||
parse_chatgpt_account_user_id(&tokens.access_token, tokens.account_id.as_deref()?)
|
||
.ok()
|
||
.flatten()
|
||
}
|
||
|
||
/// Account-facing plan classification derived from the current auth.
|
||
/// Returns a high-level `AccountPlanType` (e.g., Free/Plus/Pro/Team/…)
|
||
/// for UI or product decisions based on the user's subscription.
|
||
pub fn account_plan_type(&self) -> Option<AccountPlanType> {
|
||
if matches!(self, Self::Headers(_)) {
|
||
return None;
|
||
}
|
||
if let Self::AgentIdentity(auth) = self {
|
||
return Some(auth.plan_type());
|
||
}
|
||
if let Self::PersonalAccessToken(auth) = self {
|
||
return Some(auth.plan_type());
|
||
}
|
||
|
||
self.get_current_token_data().map(|t| {
|
||
t.id_token
|
||
.chatgpt_plan_type
|
||
.map(AccountPlanType::from)
|
||
.unwrap_or(AccountPlanType::Unknown)
|
||
})
|
||
}
|
||
|
||
pub fn is_workspace_account(&self) -> bool {
|
||
self.account_plan_type()
|
||
.is_some_and(AccountPlanType::is_workspace_account)
|
||
}
|
||
|
||
/// Returns `None` if token-backed ChatGPT auth is unavailable.
|
||
fn get_current_auth_json(&self) -> Option<AuthDotJson> {
|
||
let state = match self {
|
||
Self::Chatgpt(auth) => &auth.state,
|
||
Self::ChatgptAuthTokens(auth) => &auth.state,
|
||
Self::ApiKey(_)
|
||
| Self::Headers(_)
|
||
| Self::AgentIdentity(_)
|
||
| Self::PersonalAccessToken(_)
|
||
| Self::BedrockApiKey(_)
|
||
| Self::BedrockAccessKeys(_) => return None,
|
||
};
|
||
#[expect(clippy::unwrap_used)]
|
||
state.auth_dot_json.lock().unwrap().clone()
|
||
}
|
||
|
||
/// Returns `None` if token-backed ChatGPT auth is unavailable.
|
||
fn get_current_token_data(&self) -> Option<TokenData> {
|
||
self.get_current_auth_json().and_then(|t| t.tokens)
|
||
}
|
||
|
||
fn stored_managed_chatgpt_agent_identity_record(
|
||
&self,
|
||
account_id: &str,
|
||
) -> Option<AgentIdentityAuthRecord> {
|
||
self.get_current_auth_json()
|
||
.and_then(|auth| auth.agent_identity)
|
||
.and_then(|identity| identity.as_record().cloned())
|
||
.filter(|identity| identity.account_id == account_id)
|
||
}
|
||
|
||
fn persist_managed_chatgpt_agent_identity_record(
|
||
&self,
|
||
record: AgentIdentityAuthRecord,
|
||
) -> std::io::Result<()> {
|
||
if let Self::Chatgpt(chatgpt_auth) = self {
|
||
chatgpt_auth.persist_agent_identity_record(record)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
async fn agent_identity_auth(
|
||
&self,
|
||
policy: AgentIdentityAuthPolicy,
|
||
agent_identity_authapi_base_url: Option<&str>,
|
||
forced_chatgpt_workspace_id: Option<Vec<String>>,
|
||
auth_route_config: &AuthRouteConfig,
|
||
session_source: SessionSource,
|
||
) -> std::io::Result<Option<AgentIdentityAuth>> {
|
||
match self {
|
||
Self::AgentIdentity(auth) => Ok(Some(auth.clone())),
|
||
Self::ApiKey(_)
|
||
| Self::ChatgptAuthTokens(_)
|
||
| Self::Headers(_)
|
||
| Self::PersonalAccessToken(_)
|
||
| Self::BedrockApiKey(_)
|
||
| Self::BedrockAccessKeys(_) => Ok(None),
|
||
Self::Chatgpt(_) => {
|
||
if policy == AgentIdentityAuthPolicy::JwtOnly {
|
||
return Ok(None);
|
||
}
|
||
self.ensure_managed_chatgpt_agent_identity(
|
||
require_agent_identity_authapi_base_url(agent_identity_authapi_base_url)?,
|
||
forced_chatgpt_workspace_id,
|
||
auth_route_config,
|
||
session_source,
|
||
)
|
||
.await
|
||
.map(Some)
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn ensure_managed_chatgpt_agent_identity(
|
||
&self,
|
||
agent_identity_authapi_base_url: &str,
|
||
forced_chatgpt_workspace_id: Option<Vec<String>>,
|
||
auth_route_config: &AuthRouteConfig,
|
||
session_source: SessionSource,
|
||
) -> std::io::Result<AgentIdentityAuth> {
|
||
let binding =
|
||
ManagedChatGptAgentIdentityBinding::from_auth(self, forced_chatgpt_workspace_id)
|
||
.ok_or_else(|| std::io::Error::other("ChatGPT auth is unavailable"))?;
|
||
|
||
// JWT auth is loaded as CodexAuth::AgentIdentity; this path only reuses
|
||
// records created by the managed ChatGPT Agent Identity bootstrap.
|
||
if let Some(record) = self.stored_managed_chatgpt_agent_identity_record(&binding.account_id)
|
||
&& record_matches_managed_chatgpt_binding(&record, &binding)
|
||
{
|
||
let should_persist = record_needs_task_registration(&record);
|
||
let auth = AgentIdentityAuth::from_record(
|
||
record,
|
||
agent_identity_authapi_base_url,
|
||
auth_route_config,
|
||
)
|
||
.await
|
||
.map_err(|err| classify_bootstrap_error("agent task registration", err))?;
|
||
if should_persist {
|
||
self.persist_managed_chatgpt_agent_identity_record(auth.record().clone())?;
|
||
}
|
||
return Ok(auth);
|
||
}
|
||
|
||
let auth = register_managed_chatgpt_agent_identity(
|
||
binding,
|
||
agent_identity_authapi_base_url,
|
||
session_source,
|
||
auth_route_config,
|
||
)
|
||
.await?;
|
||
self.persist_managed_chatgpt_agent_identity_record(auth.record().clone())?;
|
||
Ok(auth)
|
||
}
|
||
|
||
/// Consider this private to integration tests.
|
||
pub fn create_dummy_chatgpt_auth_for_testing() -> Self {
|
||
let auth_dot_json = AuthDotJson {
|
||
auth_mode: Some(AuthMode::Chatgpt),
|
||
openai_api_key: None,
|
||
tokens: Some(TokenData {
|
||
id_token: Default::default(),
|
||
access_token: "Access Token".to_string(),
|
||
refresh_token: "test".to_string(),
|
||
account_id: Some("account_id".to_string()),
|
||
}),
|
||
last_refresh: Some(Utc::now()),
|
||
agent_identity: None,
|
||
personal_access_token: None,
|
||
bedrock_api_key: None,
|
||
bedrock_access_keys: None,
|
||
};
|
||
|
||
let state = ChatgptAuthState {
|
||
auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))),
|
||
client: create_client(),
|
||
};
|
||
let dummy_auth_id = NEXT_DUMMY_AUTH_ID.fetch_add(1, Ordering::Relaxed);
|
||
let storage = create_auth_storage(
|
||
PathBuf::from(format!("dummy-chatgpt-auth-{dummy_auth_id}")),
|
||
AuthCredentialsStoreMode::Ephemeral,
|
||
AuthKeyringBackendKind::default(),
|
||
);
|
||
Self::Chatgpt(ChatgptAuth { state, storage })
|
||
}
|
||
|
||
/// Constructs in-memory ChatGPT auth from externally managed tokens.
|
||
pub fn from_external_chatgpt_tokens(
|
||
access_token: &str,
|
||
chatgpt_account_id: &str,
|
||
chatgpt_plan_type: Option<&str>,
|
||
) -> std::io::Result<Self> {
|
||
let auth_dot_json = AuthDotJson::from_external_access_token(
|
||
access_token,
|
||
chatgpt_account_id,
|
||
chatgpt_plan_type,
|
||
)?;
|
||
let state = ChatgptAuthState {
|
||
auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))),
|
||
client: create_client(),
|
||
};
|
||
Ok(Self::ChatgptAuthTokens(ChatgptAuthTokens { state }))
|
||
}
|
||
|
||
pub fn from_api_key(api_key: &str) -> Self {
|
||
Self::ApiKey(ApiKeyAuth {
|
||
api_key: api_key.to_owned(),
|
||
})
|
||
}
|
||
}
|
||
|
||
impl ManagedChatGptAgentIdentityBinding {
|
||
fn from_auth(auth: &CodexAuth, forced_workspace_id: Option<Vec<String>>) -> Option<Self> {
|
||
if !auth.is_chatgpt_auth() {
|
||
return None;
|
||
}
|
||
|
||
let token_data = auth.get_token_data().ok()?;
|
||
let forced_workspace_id =
|
||
forced_workspace_id
|
||
.as_deref()
|
||
.and_then(|workspace_ids| match workspace_ids {
|
||
[workspace_id] if !workspace_id.is_empty() => Some(workspace_id.clone()),
|
||
_ => None,
|
||
});
|
||
let account_id = forced_workspace_id
|
||
.or(token_data
|
||
.account_id
|
||
.clone()
|
||
.filter(|value| !value.is_empty()))
|
||
.or(token_data.id_token.chatgpt_account_id.clone())?;
|
||
let chatgpt_user_id = token_data
|
||
.id_token
|
||
.chatgpt_user_id
|
||
.clone()
|
||
.filter(|value| !value.is_empty())?;
|
||
|
||
Some(Self {
|
||
account_id,
|
||
chatgpt_user_id,
|
||
email: token_data.id_token.email.clone(),
|
||
plan_type: auth.account_plan_type().unwrap_or(AccountPlanType::Unknown),
|
||
chatgpt_account_is_fedramp: auth.is_fedramp_account(),
|
||
access_token: token_data.access_token,
|
||
})
|
||
}
|
||
}
|
||
|
||
impl ChatgptAuth {
|
||
fn current_auth_json(&self) -> Option<AuthDotJson> {
|
||
#[expect(clippy::unwrap_used)]
|
||
self.state.auth_dot_json.lock().unwrap().clone()
|
||
}
|
||
|
||
fn current_token_data(&self) -> Option<TokenData> {
|
||
self.current_auth_json().and_then(|auth| auth.tokens)
|
||
}
|
||
|
||
fn storage(&self) -> &Arc<dyn AuthStorageBackend> {
|
||
&self.storage
|
||
}
|
||
|
||
fn client(&self) -> &HttpClient {
|
||
&self.state.client
|
||
}
|
||
|
||
fn persist_agent_identity_record(
|
||
&self,
|
||
record: AgentIdentityAuthRecord,
|
||
) -> std::io::Result<()> {
|
||
persist_agent_identity_record(&self.state.auth_dot_json, &self.storage, record)
|
||
}
|
||
}
|
||
|
||
fn persist_agent_identity_record(
|
||
auth_dot_json: &Arc<Mutex<Option<AuthDotJson>>>,
|
||
storage: &Arc<dyn AuthStorageBackend>,
|
||
record: AgentIdentityAuthRecord,
|
||
) -> std::io::Result<()> {
|
||
let mut guard = auth_dot_json
|
||
.lock()
|
||
.map_err(|_| std::io::Error::other("failed to lock auth state"))?;
|
||
let mut auth = storage
|
||
.load()?
|
||
.or_else(|| guard.clone())
|
||
.ok_or_else(|| std::io::Error::other("auth data is not available"))?;
|
||
auth.agent_identity = Some(AgentIdentityStorage::Record(record));
|
||
storage.save(&auth)?;
|
||
*guard = Some(auth);
|
||
Ok(())
|
||
}
|
||
|
||
pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
|
||
pub const CODEX_API_KEY_ENV_VAR: &str = "CODEX_API_KEY";
|
||
pub const CODEX_ACCESS_TOKEN_ENV_VAR: &str = "CODEX_ACCESS_TOKEN";
|
||
|
||
pub fn read_openai_api_key_from_env() -> Option<String> {
|
||
env::var(OPENAI_API_KEY_ENV_VAR)
|
||
.ok()
|
||
.map(|value| value.trim().to_string())
|
||
.filter(|value| !value.is_empty())
|
||
}
|
||
|
||
pub fn read_codex_api_key_from_env() -> Option<String> {
|
||
read_non_empty_env_var(CODEX_API_KEY_ENV_VAR)
|
||
}
|
||
|
||
pub fn read_codex_access_token_from_env() -> Option<String> {
|
||
read_non_empty_env_var(CODEX_ACCESS_TOKEN_ENV_VAR)
|
||
}
|
||
|
||
fn read_non_empty_env_var(key: &str) -> Option<String> {
|
||
env::var(key)
|
||
.ok()
|
||
.map(|value| value.trim().to_string())
|
||
.filter(|value| !value.is_empty())
|
||
}
|
||
|
||
/// Delete credentials for `codex_home` through the selected storage backend.
|
||
/// Returns `Ok(true)` if credentials were removed, `Ok(false)` if none were present.
|
||
pub fn logout(
|
||
codex_home: &Path,
|
||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||
keyring_backend_kind: AuthKeyringBackendKind,
|
||
) -> std::io::Result<bool> {
|
||
let storage = create_auth_storage(
|
||
codex_home.to_path_buf(),
|
||
auth_credentials_store_mode,
|
||
keyring_backend_kind,
|
||
);
|
||
storage.delete()
|
||
}
|
||
|
||
pub async fn logout_with_revoke(
|
||
codex_home: &Path,
|
||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||
keyring_backend_kind: AuthKeyringBackendKind,
|
||
auth_route_config: &AuthRouteConfig,
|
||
) -> std::io::Result<bool> {
|
||
let auth_dot_json = match load_auth_dot_json(
|
||
codex_home,
|
||
auth_credentials_store_mode,
|
||
keyring_backend_kind,
|
||
) {
|
||
Ok(auth_dot_json) => auth_dot_json,
|
||
Err(err) => {
|
||
tracing::warn!("failed to load stored auth during logout: {err}");
|
||
None
|
||
}
|
||
};
|
||
if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref(), auth_route_config).await {
|
||
tracing::warn!("failed to revoke auth tokens during logout: {err}");
|
||
}
|
||
logout_all_stores(
|
||
codex_home,
|
||
auth_credentials_store_mode,
|
||
keyring_backend_kind,
|
||
)
|
||
}
|
||
|
||
/// Store API-key credentials through the selected storage backend.
|
||
pub fn login_with_api_key(
|
||
codex_home: &Path,
|
||
api_key: &str,
|
||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||
keyring_backend_kind: AuthKeyringBackendKind,
|
||
) -> std::io::Result<()> {
|
||
let auth_dot_json = AuthDotJson {
|
||
auth_mode: Some(AuthMode::ApiKey),
|
||
openai_api_key: Some(api_key.to_string()),
|
||
tokens: None,
|
||
last_refresh: None,
|
||
agent_identity: None,
|
||
personal_access_token: None,
|
||
bedrock_api_key: None,
|
||
bedrock_access_keys: None,
|
||
};
|
||
save_auth(
|
||
codex_home,
|
||
&auth_dot_json,
|
||
auth_credentials_store_mode,
|
||
keyring_backend_kind,
|
||
)
|
||
}
|
||
|
||
/// Validate and store personal-access-token or Agent Identity credentials through
|
||
/// the selected storage backend.
|
||
pub async fn login_with_access_token(
|
||
codex_home: &Path,
|
||
access_token: &str,
|
||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||
forced_chatgpt_workspace_id: Option<&[String]>,
|
||
chatgpt_base_url: Option<&str>,
|
||
keyring_backend_kind: AuthKeyringBackendKind,
|
||
auth_route_config: &AuthRouteConfig,
|
||
) -> std::io::Result<()> {
|
||
let auth_dot_json = match classify_codex_access_token(access_token) {
|
||
CodexAccessToken::PersonalAccessToken(access_token) => {
|
||
let auth = PersonalAccessTokenAuth::load(access_token, auth_route_config).await?;
|
||
ensure_auth_workspace_allowed(forced_chatgpt_workspace_id, auth.account_id())?;
|
||
AuthDotJson {
|
||
// Infer PAT auth from the credential field so older Codex builds can still
|
||
// deserialize auth.json after a rollback.
|
||
auth_mode: None,
|
||
openai_api_key: None,
|
||
tokens: None,
|
||
last_refresh: None,
|
||
agent_identity: None,
|
||
personal_access_token: Some(access_token.to_string()),
|
||
bedrock_api_key: None,
|
||
bedrock_access_keys: None,
|
||
}
|
||
}
|
||
CodexAccessToken::AgentIdentityJwt(jwt) => {
|
||
let record = AgentIdentityAuthRecord::from_agent_identity_jwt(jwt)?;
|
||
ensure_auth_workspace_allowed(forced_chatgpt_workspace_id, &record.account_id)?;
|
||
let base_url = chatgpt_base_url
|
||
.unwrap_or(ChatGptEnvironment::default().chatgpt_base_url())
|
||
.trim_end_matches('/')
|
||
.to_string();
|
||
verified_record_from_jwt(jwt, &base_url, auth_route_config).await?;
|
||
AuthDotJson {
|
||
auth_mode: Some(AuthMode::AgentIdentity),
|
||
openai_api_key: None,
|
||
tokens: None,
|
||
last_refresh: None,
|
||
agent_identity: Some(AgentIdentityStorage::Jwt(jwt.to_string())),
|
||
personal_access_token: None,
|
||
bedrock_api_key: None,
|
||
bedrock_access_keys: None,
|
||
}
|
||
}
|
||
};
|
||
save_auth(
|
||
codex_home,
|
||
&auth_dot_json,
|
||
auth_credentials_store_mode,
|
||
keyring_backend_kind,
|
||
)
|
||
}
|
||
|
||
fn ensure_auth_workspace_allowed(
|
||
expected_workspace_ids: Option<&[String]>,
|
||
account_id: &str,
|
||
) -> std::io::Result<()> {
|
||
crate::server::ensure_workspace_account_allowed(expected_workspace_ids, account_id)
|
||
.map_err(|message| std::io::Error::new(std::io::ErrorKind::PermissionDenied, message))
|
||
}
|
||
|
||
fn ensure_agent_identity_workspace_allowed(
|
||
expected_workspace_ids: Option<&[String]>,
|
||
agent_identity: &AgentIdentityStorage,
|
||
) -> std::io::Result<()> {
|
||
let Some(expected_workspace_ids) = expected_workspace_ids else {
|
||
return Ok(());
|
||
};
|
||
|
||
match agent_identity {
|
||
AgentIdentityStorage::Jwt(jwt) => {
|
||
let record = AgentIdentityAuthRecord::from_agent_identity_jwt(jwt)?;
|
||
ensure_auth_workspace_allowed(Some(expected_workspace_ids), &record.account_id)
|
||
}
|
||
AgentIdentityStorage::Record(record) => {
|
||
ensure_auth_workspace_allowed(Some(expected_workspace_ids), &record.account_id)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Writes an in-memory auth payload for externally managed ChatGPT tokens.
|
||
pub fn login_with_chatgpt_auth_tokens(
|
||
codex_home: &Path,
|
||
access_token: &str,
|
||
chatgpt_account_id: &str,
|
||
chatgpt_plan_type: Option<&str>,
|
||
) -> std::io::Result<()> {
|
||
let auth_dot_json = AuthDotJson::from_external_access_token(
|
||
access_token,
|
||
chatgpt_account_id,
|
||
chatgpt_plan_type,
|
||
)?;
|
||
save_auth(
|
||
codex_home,
|
||
&auth_dot_json,
|
||
AuthCredentialsStoreMode::Ephemeral,
|
||
AuthKeyringBackendKind::default(),
|
||
)
|
||
}
|
||
|
||
/// Persist the provided auth payload using the specified backend.
|
||
pub fn save_auth(
|
||
codex_home: &Path,
|
||
auth: &AuthDotJson,
|
||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||
keyring_backend_kind: AuthKeyringBackendKind,
|
||
) -> std::io::Result<()> {
|
||
let storage = create_auth_storage(
|
||
codex_home.to_path_buf(),
|
||
auth_credentials_store_mode,
|
||
keyring_backend_kind,
|
||
);
|
||
storage.save(auth)
|
||
}
|
||
|
||
/// Load the raw stored auth payload without applying environment overrides.
|
||
///
|
||
/// Returns `None` when no credentials are stored. Prefer `AuthManager` for
|
||
/// ordinary production reads; this helper is for tests and write-side
|
||
/// maintenance that must inspect the exact payload in storage.
|
||
pub fn load_auth_dot_json(
|
||
codex_home: &Path,
|
||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||
keyring_backend_kind: AuthKeyringBackendKind,
|
||
) -> std::io::Result<Option<AuthDotJson>> {
|
||
let storage = create_auth_storage(
|
||
codex_home.to_path_buf(),
|
||
auth_credentials_store_mode,
|
||
keyring_backend_kind,
|
||
);
|
||
storage.load()
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct AuthConfig {
|
||
pub codex_home: PathBuf,
|
||
pub auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||
pub keyring_backend_kind: AuthKeyringBackendKind,
|
||
pub forced_login_method: Option<ForcedLoginMethod>,
|
||
pub chatgpt_base_url: Option<String>,
|
||
pub forced_chatgpt_workspace_id: Option<Vec<String>>,
|
||
pub managed_auth_policy: ManagedAuthPolicy,
|
||
pub auth_route_config: AuthRouteConfig,
|
||
}
|
||
|
||
impl AuthConfig {
|
||
pub fn is_login_method_allowed(&self, method: ForcedLoginMethod) -> bool {
|
||
self.managed_auth_policy.allows_login_method(
|
||
method,
|
||
self.forced_login_method,
|
||
self.forced_chatgpt_workspace_id.as_deref(),
|
||
)
|
||
}
|
||
|
||
pub fn effective_chatgpt_workspaces(&self) -> Option<Vec<String>> {
|
||
self.managed_auth_policy
|
||
.effective_chatgpt_workspaces(self.forced_chatgpt_workspace_id.as_deref())
|
||
}
|
||
|
||
pub fn validate(&self) -> std::io::Result<()> {
|
||
if self.is_login_method_allowed(ForcedLoginMethod::Api)
|
||
|| self.is_login_method_allowed(ForcedLoginMethod::Chatgpt)
|
||
{
|
||
Ok(())
|
||
} else {
|
||
Err(std::io::Error::new(
|
||
std::io::ErrorKind::PermissionDenied,
|
||
"authentication requirements do not permit any usable login method",
|
||
))
|
||
}
|
||
}
|
||
|
||
pub fn allows_auth(&self, auth: &CodexAuth) -> bool {
|
||
let allowed_login_methods = self.allowed_login_methods();
|
||
let workspaces = self.effective_chatgpt_workspaces();
|
||
validate_auth_restrictions(Some(&allowed_login_methods), workspaces.as_deref(), auth)
|
||
.is_ok()
|
||
}
|
||
|
||
pub async fn load_auth(
|
||
&self,
|
||
enable_codex_api_key_env: bool,
|
||
) -> std::io::Result<Option<CodexAuth>> {
|
||
let allowed_login_methods = self.allowed_login_methods();
|
||
let workspaces = self.effective_chatgpt_workspaces();
|
||
let agent_identity_authapi_base_url =
|
||
agent_identity_authapi_base_url(self.chatgpt_base_url.as_deref()).ok();
|
||
let auth = load_auth(
|
||
&self.codex_home,
|
||
enable_codex_api_key_env,
|
||
self.auth_credentials_store_mode,
|
||
Some(&allowed_login_methods),
|
||
workspaces.as_deref(),
|
||
self.chatgpt_base_url.as_deref(),
|
||
self.keyring_backend_kind,
|
||
agent_identity_authapi_base_url.as_deref(),
|
||
&self.auth_route_config,
|
||
)
|
||
.await?;
|
||
Ok(auth.filter(|auth| self.allows_auth(auth)))
|
||
}
|
||
|
||
fn allowed_login_methods(&self) -> Vec<ForcedLoginMethod> {
|
||
self.managed_auth_policy.allowed_login_methods(
|
||
self.forced_login_method,
|
||
self.forced_chatgpt_workspace_id.as_deref(),
|
||
)
|
||
}
|
||
}
|
||
|
||
fn auth_mode_is_allowed(
|
||
allowed_login_methods: Option<&[ForcedLoginMethod]>,
|
||
mode: AuthMode,
|
||
) -> bool {
|
||
let method = if mode.uses_codex_backend() {
|
||
ForcedLoginMethod::Chatgpt
|
||
} else {
|
||
ForcedLoginMethod::Api
|
||
};
|
||
allowed_login_methods.is_none_or(|allowed| allowed.contains(&method))
|
||
}
|
||
|
||
fn validate_auth_restrictions(
|
||
allowed_login_methods: Option<&[ForcedLoginMethod]>,
|
||
expected_workspaces: Option<&[String]>,
|
||
auth: &CodexAuth,
|
||
) -> Result<(), String> {
|
||
if !auth_mode_is_allowed(allowed_login_methods, auth.auth_mode()) {
|
||
return Err(match allowed_login_methods {
|
||
Some(methods) if methods.contains(&ForcedLoginMethod::Api) => {
|
||
"API key login is required".to_string()
|
||
}
|
||
Some(_) => "ChatGPT login is required".to_string(),
|
||
None => unreachable!("unrestricted login methods accept every auth mode"),
|
||
});
|
||
}
|
||
|
||
let Some(expected_workspaces) = expected_workspaces else {
|
||
return Ok(());
|
||
};
|
||
if matches!(
|
||
auth,
|
||
CodexAuth::ApiKey(_) | CodexAuth::BedrockApiKey(_) | CodexAuth::BedrockAccessKeys(_)
|
||
) {
|
||
return Ok(());
|
||
}
|
||
|
||
let actual_workspace = auth.get_account_id().or_else(|| {
|
||
auth.get_token_data()
|
||
.ok()
|
||
.and_then(|tokens| tokens.id_token.chatgpt_account_id)
|
||
});
|
||
if actual_workspace
|
||
.as_ref()
|
||
.is_some_and(|workspace| expected_workspaces.contains(workspace))
|
||
{
|
||
Ok(())
|
||
} else {
|
||
let actual = actual_workspace.unwrap_or_else(|| "unknown".to_string());
|
||
Err(format!(
|
||
"Login is restricted to workspace(s) {}, but current credentials belong to {actual}",
|
||
expected_workspaces.join(", ")
|
||
))
|
||
}
|
||
}
|
||
|
||
/// Enforces configured login restrictions using auth-owned HTTP settings.
|
||
pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> {
|
||
let agent_identity_authapi_base_url =
|
||
agent_identity_authapi_base_url(config.chatgpt_base_url.as_deref()).ok();
|
||
enforce_login_restrictions_with_agent_identity_authapi_base_url(
|
||
config,
|
||
agent_identity_authapi_base_url.as_deref(),
|
||
)
|
||
.await
|
||
}
|
||
|
||
async fn enforce_login_restrictions_with_agent_identity_authapi_base_url(
|
||
config: &AuthConfig,
|
||
agent_identity_authapi_base_url: Option<&str>,
|
||
) -> std::io::Result<()> {
|
||
// Managed-only restrictions are enforced by AuthManager.
|
||
if config.forced_login_method.is_none() && config.forced_chatgpt_workspace_id.is_none() {
|
||
return Ok(());
|
||
}
|
||
|
||
let Some(auth) = load_auth(
|
||
&config.codex_home,
|
||
/*enable_codex_api_key_env*/ true,
|
||
config.auth_credentials_store_mode,
|
||
/*allowed_login_methods*/ None,
|
||
/*forced_chatgpt_workspace_id*/ None,
|
||
config.chatgpt_base_url.as_deref(),
|
||
config.keyring_backend_kind,
|
||
agent_identity_authapi_base_url,
|
||
&config.auth_route_config,
|
||
)
|
||
.await?
|
||
else {
|
||
return Ok(());
|
||
};
|
||
|
||
if let Some(required_method) = config.forced_login_method {
|
||
let method_violation = match (required_method, auth.auth_mode()) {
|
||
(ForcedLoginMethod::Api, AuthMode::ApiKey)
|
||
| (ForcedLoginMethod::Api, AuthMode::BedrockApiKey)
|
||
| (ForcedLoginMethod::Api, AuthMode::BedrockAccessKeys) => None,
|
||
(ForcedLoginMethod::Chatgpt, AuthMode::Chatgpt)
|
||
| (ForcedLoginMethod::Chatgpt, AuthMode::ChatgptAuthTokens)
|
||
| (ForcedLoginMethod::Chatgpt, AuthMode::Headers)
|
||
| (ForcedLoginMethod::Chatgpt, AuthMode::AgentIdentity)
|
||
| (ForcedLoginMethod::Chatgpt, AuthMode::PersonalAccessToken) => None,
|
||
(ForcedLoginMethod::Api, AuthMode::Chatgpt)
|
||
| (ForcedLoginMethod::Api, AuthMode::ChatgptAuthTokens)
|
||
| (ForcedLoginMethod::Api, AuthMode::Headers)
|
||
| (ForcedLoginMethod::Api, AuthMode::AgentIdentity)
|
||
| (ForcedLoginMethod::Api, AuthMode::PersonalAccessToken) => Some(
|
||
"API key login is required, but ChatGPT is currently being used. Logging out."
|
||
.to_string(),
|
||
),
|
||
(ForcedLoginMethod::Chatgpt, AuthMode::ApiKey)
|
||
| (ForcedLoginMethod::Chatgpt, AuthMode::BedrockApiKey)
|
||
| (ForcedLoginMethod::Chatgpt, AuthMode::BedrockAccessKeys) => Some(
|
||
"ChatGPT login is required, but an API key is currently being used. Logging out."
|
||
.to_string(),
|
||
),
|
||
};
|
||
|
||
if let Some(message) = method_violation {
|
||
return logout_with_message(
|
||
&config.codex_home,
|
||
message,
|
||
config.auth_credentials_store_mode,
|
||
config.keyring_backend_kind,
|
||
);
|
||
}
|
||
}
|
||
|
||
if let Some(expected_account_ids) = config.forced_chatgpt_workspace_id.as_deref() {
|
||
let chatgpt_account_id = match &auth {
|
||
CodexAuth::ApiKey(_)
|
||
| CodexAuth::BedrockApiKey(_)
|
||
| CodexAuth::BedrockAccessKeys(_) => {
|
||
return Ok(());
|
||
}
|
||
CodexAuth::Headers(_)
|
||
| CodexAuth::AgentIdentity(_)
|
||
| CodexAuth::PersonalAccessToken(_) => auth.get_account_id(),
|
||
CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) => {
|
||
let token_data = match auth.get_token_data() {
|
||
Ok(data) => data,
|
||
Err(err) => {
|
||
return logout_with_message(
|
||
&config.codex_home,
|
||
format!(
|
||
"Failed to load ChatGPT credentials while enforcing workspace restrictions: {err}. Logging out."
|
||
),
|
||
config.auth_credentials_store_mode,
|
||
config.keyring_backend_kind,
|
||
);
|
||
}
|
||
};
|
||
token_data.id_token.chatgpt_account_id
|
||
}
|
||
};
|
||
|
||
// workspace is the external identifier for account id.
|
||
let chatgpt_account_id = chatgpt_account_id.as_deref();
|
||
if !chatgpt_account_id.is_some_and(|actual| {
|
||
expected_account_ids
|
||
.iter()
|
||
.any(|expected| expected == actual)
|
||
}) {
|
||
let expected_workspaces = expected_account_ids.join(", ");
|
||
let message = match chatgpt_account_id {
|
||
Some(actual) => format!(
|
||
"Login is restricted to workspace(s) {expected_workspaces}, but current credentials belong to {actual}. Logging out."
|
||
),
|
||
None => format!(
|
||
"Login is restricted to workspace(s) {expected_workspaces}, but current credentials lack a workspace identifier. Logging out."
|
||
),
|
||
};
|
||
return logout_with_message(
|
||
&config.codex_home,
|
||
message,
|
||
config.auth_credentials_store_mode,
|
||
config.keyring_backend_kind,
|
||
);
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
fn logout_with_message(
|
||
codex_home: &Path,
|
||
message: String,
|
||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||
keyring_backend_kind: AuthKeyringBackendKind,
|
||
) -> std::io::Result<()> {
|
||
// External auth tokens live in the ephemeral store, but persistent auth may still exist
|
||
// from earlier logins. Clear both so a forced logout truly removes all active auth.
|
||
let removal_result = logout_all_stores(
|
||
codex_home,
|
||
auth_credentials_store_mode,
|
||
keyring_backend_kind,
|
||
);
|
||
let error_message = match removal_result {
|
||
Ok(_) => message,
|
||
Err(err) => format!("{message}. Failed to remove stored credentials: {err}"),
|
||
};
|
||
Err(std::io::Error::other(error_message))
|
||
}
|
||
|
||
fn logout_all_stores(
|
||
codex_home: &Path,
|
||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||
keyring_backend_kind: AuthKeyringBackendKind,
|
||
) -> std::io::Result<bool> {
|
||
if auth_credentials_store_mode == AuthCredentialsStoreMode::Ephemeral {
|
||
return logout(
|
||
codex_home,
|
||
AuthCredentialsStoreMode::Ephemeral,
|
||
AuthKeyringBackendKind::default(),
|
||
);
|
||
}
|
||
let removed_ephemeral = logout(
|
||
codex_home,
|
||
AuthCredentialsStoreMode::Ephemeral,
|
||
AuthKeyringBackendKind::default(),
|
||
)?;
|
||
let removed_managed = logout(
|
||
codex_home,
|
||
auth_credentials_store_mode,
|
||
keyring_backend_kind,
|
||
)?;
|
||
Ok(removed_ephemeral || removed_managed)
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
async fn load_auth(
|
||
codex_home: &Path,
|
||
enable_codex_api_key_env: bool,
|
||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||
allowed_login_methods: Option<&[ForcedLoginMethod]>,
|
||
forced_chatgpt_workspace_id: Option<&[String]>,
|
||
chatgpt_base_url: Option<&str>,
|
||
keyring_backend_kind: AuthKeyringBackendKind,
|
||
agent_identity_authapi_base_url: Option<&str>,
|
||
auth_route_config: &AuthRouteConfig,
|
||
) -> std::io::Result<Option<CodexAuth>> {
|
||
// API key via env var takes precedence over any other auth method.
|
||
if enable_codex_api_key_env
|
||
&& auth_mode_is_allowed(allowed_login_methods, AuthMode::ApiKey)
|
||
&& let Some(api_key) = read_codex_api_key_from_env()
|
||
{
|
||
return Ok(Some(CodexAuth::from_api_key(api_key.as_str())));
|
||
}
|
||
|
||
// External ChatGPT auth tokens live in the in-memory (ephemeral) store. Always check this
|
||
// first so external auth takes precedence over any persisted credentials.
|
||
let ephemeral_storage = create_auth_storage(
|
||
codex_home.to_path_buf(),
|
||
AuthCredentialsStoreMode::Ephemeral,
|
||
AuthKeyringBackendKind::default(),
|
||
);
|
||
if let Some(auth_dot_json) = ephemeral_storage.load()?
|
||
&& auth_mode_is_allowed(allowed_login_methods, auth_dot_json.resolved_mode())
|
||
{
|
||
if let Some(agent_identity) = auth_dot_json.agent_identity.as_ref() {
|
||
ensure_agent_identity_workspace_allowed(forced_chatgpt_workspace_id, agent_identity)?;
|
||
}
|
||
let auth = CodexAuth::from_auth_dot_json(
|
||
codex_home,
|
||
auth_dot_json,
|
||
AuthCredentialsStoreMode::Ephemeral,
|
||
chatgpt_base_url,
|
||
keyring_backend_kind,
|
||
agent_identity_authapi_base_url,
|
||
auth_route_config,
|
||
)
|
||
.await?;
|
||
if let CodexAuth::PersonalAccessToken(auth) = &auth {
|
||
ensure_auth_workspace_allowed(forced_chatgpt_workspace_id, auth.account_id())?;
|
||
}
|
||
return Ok(Some(auth));
|
||
}
|
||
|
||
if auth_mode_is_allowed(allowed_login_methods, AuthMode::AgentIdentity)
|
||
&& let Some(access_token) = read_codex_access_token_from_env()
|
||
{
|
||
return match classify_codex_access_token(&access_token) {
|
||
CodexAccessToken::PersonalAccessToken(access_token) => {
|
||
let auth = PersonalAccessTokenAuth::load(access_token, auth_route_config).await?;
|
||
ensure_auth_workspace_allowed(forced_chatgpt_workspace_id, auth.account_id())?;
|
||
Ok(Some(CodexAuth::PersonalAccessToken(auth)))
|
||
}
|
||
CodexAccessToken::AgentIdentityJwt(jwt) => {
|
||
let record = AgentIdentityAuthRecord::from_agent_identity_jwt(jwt)?;
|
||
ensure_auth_workspace_allowed(forced_chatgpt_workspace_id, &record.account_id)?;
|
||
CodexAuth::from_agent_identity_jwt_with_authapi_base_url(
|
||
jwt,
|
||
chatgpt_base_url,
|
||
require_agent_identity_authapi_base_url(agent_identity_authapi_base_url)?,
|
||
auth_route_config,
|
||
)
|
||
}
|
||
.await
|
||
.map(Some),
|
||
};
|
||
}
|
||
|
||
// If the caller explicitly requested ephemeral auth, there is no persisted fallback.
|
||
if auth_credentials_store_mode == AuthCredentialsStoreMode::Ephemeral {
|
||
return Ok(None);
|
||
}
|
||
|
||
// Fall back to the configured persistent store (file/keyring/auto) for managed auth.
|
||
let storage = create_auth_storage(
|
||
codex_home.to_path_buf(),
|
||
auth_credentials_store_mode,
|
||
keyring_backend_kind,
|
||
);
|
||
let auth_dot_json = match storage.load()? {
|
||
Some(auth) => auth,
|
||
None => return Ok(None),
|
||
};
|
||
if !auth_mode_is_allowed(allowed_login_methods, auth_dot_json.resolved_mode()) {
|
||
return Ok(None);
|
||
}
|
||
if let Some(agent_identity) = auth_dot_json.agent_identity.as_ref() {
|
||
ensure_agent_identity_workspace_allowed(forced_chatgpt_workspace_id, agent_identity)?;
|
||
}
|
||
|
||
let auth = CodexAuth::from_auth_dot_json(
|
||
codex_home,
|
||
auth_dot_json,
|
||
auth_credentials_store_mode,
|
||
chatgpt_base_url,
|
||
keyring_backend_kind,
|
||
agent_identity_authapi_base_url,
|
||
auth_route_config,
|
||
)
|
||
.await?;
|
||
if let CodexAuth::PersonalAccessToken(auth) = &auth {
|
||
ensure_auth_workspace_allowed(forced_chatgpt_workspace_id, auth.account_id())?;
|
||
}
|
||
Ok(Some(auth))
|
||
}
|
||
|
||
// Persist refreshed tokens into auth storage and update last_refresh.
|
||
fn persist_tokens(
|
||
storage: &Arc<dyn AuthStorageBackend>,
|
||
id_token: Option<String>,
|
||
access_token: Option<String>,
|
||
refresh_token: Option<String>,
|
||
) -> std::io::Result<AuthDotJson> {
|
||
let mut auth_dot_json = storage
|
||
.load()?
|
||
.ok_or(std::io::Error::other("Token data is not available."))?;
|
||
|
||
let tokens = auth_dot_json.tokens.get_or_insert_with(TokenData::default);
|
||
if let Some(id_token) = id_token {
|
||
tokens.id_token = parse_chatgpt_jwt_claims(&id_token).map_err(std::io::Error::other)?;
|
||
}
|
||
if let Some(access_token) = access_token {
|
||
tokens.access_token = access_token;
|
||
}
|
||
if let Some(refresh_token) = refresh_token {
|
||
tokens.refresh_token = refresh_token;
|
||
}
|
||
auth_dot_json.last_refresh = Some(Utc::now());
|
||
storage.save(&auth_dot_json)?;
|
||
Ok(auth_dot_json)
|
||
}
|
||
|
||
// Requests refreshed ChatGPT OAuth tokens from the auth service using a refresh token.
|
||
// The caller is responsible for persisting any returned tokens.
|
||
async fn request_chatgpt_token_refresh(
|
||
refresh_token: String,
|
||
client: &HttpClient,
|
||
refresh_reason: RefreshReason,
|
||
) -> Result<RefreshResponse, RefreshTokenError> {
|
||
let client_id = oauth_client_id();
|
||
let endpoint = refresh_token_endpoint();
|
||
let oauth = OAuthClient::new(
|
||
client,
|
||
TokenEndpoint {
|
||
url: &endpoint,
|
||
client_id: &client_id,
|
||
encoding: TokenEncoding::Json,
|
||
timeout: None,
|
||
error_body_limit: ErrorBodyLimit::Unlimited,
|
||
},
|
||
);
|
||
match oauth
|
||
.refresh(RefreshTokenGrant {
|
||
refresh_token: &refresh_token,
|
||
resource: None,
|
||
refresh_reason: Some(refresh_reason),
|
||
})
|
||
.await
|
||
{
|
||
Ok(response) => Ok(response),
|
||
Err(OAuthError::Rejected(rejection)) => {
|
||
if let Some(codex_http_client::HttpError::Policy(denied)) = rejection.body_read_error {
|
||
return Err(denied.into());
|
||
}
|
||
let status = rejection.status;
|
||
let detail = &rejection.detail;
|
||
tracing::error!(%status, ?detail, "Failed to refresh token");
|
||
let code = detail.error_code();
|
||
let is_invalid_grant_bad_request = status == StatusCode::BAD_REQUEST
|
||
&& code.is_some_and(|code| code.eq_ignore_ascii_case("invalid_grant"));
|
||
let failed = classify_refresh_token_failure(code, detail, is_invalid_grant_bad_request);
|
||
if status == StatusCode::UNAUTHORIZED
|
||
|| failed.reason != RefreshTokenFailedReason::Other
|
||
|| is_invalid_grant_bad_request
|
||
{
|
||
Err(RefreshTokenError::Permanent(failed))
|
||
} else {
|
||
Err(RefreshTokenError::Transient(std::io::Error::other(
|
||
format!("Failed to refresh token: {status}: {detail}"),
|
||
)))
|
||
}
|
||
}
|
||
Err(OAuthError::Transport(error)) => Err(error.into()),
|
||
Err(error @ OAuthError::InvalidResponse) => {
|
||
Err(RefreshTokenError::Transient(std::io::Error::other(error)))
|
||
}
|
||
}
|
||
}
|
||
|
||
fn classify_refresh_token_failure(
|
||
code: Option<&str>,
|
||
detail: &TokenErrorDetail,
|
||
is_invalid_grant_bad_request: bool,
|
||
) -> RefreshTokenFailedError {
|
||
let normalized_code = code.map(str::to_ascii_lowercase);
|
||
let reason = match normalized_code.as_deref() {
|
||
Some("refresh_token_expired") => RefreshTokenFailedReason::Expired,
|
||
Some("refresh_token_reused") => RefreshTokenFailedReason::Exhausted,
|
||
Some("refresh_token_invalidated") => RefreshTokenFailedReason::Revoked,
|
||
_ => RefreshTokenFailedReason::Other,
|
||
};
|
||
|
||
if reason == RefreshTokenFailedReason::Other && !is_invalid_grant_bad_request {
|
||
tracing::warn!(
|
||
backend_detail = ?detail,
|
||
"Encountered unknown response while refreshing token"
|
||
);
|
||
}
|
||
|
||
let message = match reason {
|
||
RefreshTokenFailedReason::Expired => REFRESH_TOKEN_EXPIRED_MESSAGE.to_string(),
|
||
RefreshTokenFailedReason::Exhausted => REFRESH_TOKEN_REUSED_MESSAGE.to_string(),
|
||
RefreshTokenFailedReason::Revoked => REFRESH_TOKEN_INVALIDATED_MESSAGE.to_string(),
|
||
RefreshTokenFailedReason::Other => REFRESH_TOKEN_UNKNOWN_MESSAGE.to_string(),
|
||
};
|
||
|
||
RefreshTokenFailedError::new(reason, message)
|
||
}
|
||
|
||
#[derive(Deserialize, Clone)]
|
||
struct RefreshResponse {
|
||
id_token: Option<String>,
|
||
access_token: Option<String>,
|
||
refresh_token: Option<String>,
|
||
}
|
||
|
||
// Shared constant for token refresh (client id used for oauth token refresh flow)
|
||
pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||
|
||
pub fn oauth_client_id() -> String {
|
||
std::env::var(CLIENT_ID_OVERRIDE_ENV_VAR)
|
||
.ok()
|
||
.filter(|client_id| !client_id.trim().is_empty())
|
||
.unwrap_or_else(|| CLIENT_ID.to_string())
|
||
}
|
||
|
||
fn refresh_token_endpoint() -> String {
|
||
std::env::var(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR)
|
||
.unwrap_or_else(|_| REFRESH_TOKEN_URL.to_string())
|
||
}
|
||
|
||
impl AuthDotJson {
|
||
fn from_external_access_token(
|
||
access_token: &str,
|
||
chatgpt_account_id: &str,
|
||
chatgpt_plan_type: Option<&str>,
|
||
) -> std::io::Result<Self> {
|
||
let mut token_info =
|
||
parse_chatgpt_jwt_claims(access_token).map_err(std::io::Error::other)?;
|
||
token_info.chatgpt_account_id = Some(chatgpt_account_id.to_string());
|
||
token_info.chatgpt_plan_type = chatgpt_plan_type
|
||
.map(InternalPlanType::from_raw_value)
|
||
.or(token_info.chatgpt_plan_type)
|
||
.or(Some(InternalPlanType::Unknown("unknown".to_string())));
|
||
let tokens = TokenData {
|
||
id_token: token_info,
|
||
access_token: access_token.to_string(),
|
||
refresh_token: String::new(),
|
||
account_id: Some(chatgpt_account_id.to_string()),
|
||
};
|
||
|
||
Ok(Self {
|
||
auth_mode: Some(AuthMode::ChatgptAuthTokens),
|
||
openai_api_key: None,
|
||
tokens: Some(tokens),
|
||
last_refresh: Some(Utc::now()),
|
||
agent_identity: None,
|
||
personal_access_token: None,
|
||
bedrock_api_key: None,
|
||
bedrock_access_keys: None,
|
||
})
|
||
}
|
||
|
||
pub(super) fn resolved_mode(&self) -> AuthMode {
|
||
if let Some(mode) = self.auth_mode {
|
||
return mode;
|
||
}
|
||
if self.personal_access_token.is_some() {
|
||
return AuthMode::PersonalAccessToken;
|
||
}
|
||
if self.bedrock_api_key.is_some() {
|
||
return AuthMode::BedrockApiKey;
|
||
}
|
||
if self.bedrock_access_keys.is_some() {
|
||
return AuthMode::BedrockAccessKeys;
|
||
}
|
||
if self.openai_api_key.is_some() {
|
||
return AuthMode::ApiKey;
|
||
}
|
||
AuthMode::Chatgpt
|
||
}
|
||
|
||
fn storage_mode(
|
||
&self,
|
||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||
) -> AuthCredentialsStoreMode {
|
||
if self.resolved_mode() == AuthMode::ChatgptAuthTokens {
|
||
AuthCredentialsStoreMode::Ephemeral
|
||
} else {
|
||
auth_credentials_store_mode
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Internal cached auth state.
|
||
#[derive(Clone)]
|
||
struct CachedAuth {
|
||
auth: Option<CodexAuth>,
|
||
/// Permanent refresh failure cached for the current auth snapshot so
|
||
/// later refresh attempts for the same credentials fail fast without network.
|
||
permanent_refresh_failure: Option<AuthScopedRefreshFailure>,
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
struct AuthScopedRefreshFailure {
|
||
auth: CodexAuth,
|
||
error: RefreshTokenFailedError,
|
||
}
|
||
|
||
impl Debug for CachedAuth {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
f.debug_struct("CachedAuth")
|
||
.field(
|
||
"auth_mode",
|
||
&self.auth.as_ref().map(CodexAuth::api_auth_mode),
|
||
)
|
||
.field(
|
||
"permanent_refresh_failure",
|
||
&self
|
||
.permanent_refresh_failure
|
||
.as_ref()
|
||
.map(|failure| failure.error.reason),
|
||
)
|
||
.finish()
|
||
}
|
||
}
|
||
|
||
enum UnauthorizedRecoveryStep {
|
||
Reload,
|
||
RefreshToken,
|
||
ExternalRefresh,
|
||
Done,
|
||
}
|
||
|
||
enum ReloadOutcome {
|
||
/// Reload was performed and the cached auth changed
|
||
ReloadedChanged,
|
||
/// Reload was performed and the cached auth remained the same
|
||
ReloadedNoChange,
|
||
/// Reload was skipped (missing or mismatched account id)
|
||
Skipped,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum UnauthorizedRecoveryMode {
|
||
Managed,
|
||
External,
|
||
}
|
||
|
||
// UnauthorizedRecovery is a state machine that handles an attempt to refresh the authentication when requests
|
||
// to API fail with 401 status code.
|
||
// The client calls next() every time it encounters a 401 error, one time per retry.
|
||
// For API key based authentication, we don't do anything and let the error bubble to the user.
|
||
//
|
||
// For ChatGPT based authentication, we:
|
||
// 1. Attempt to reload the auth data from disk. We only reload if the account id matches the one the current process is running as.
|
||
// 2. Attempt to refresh the token using OAuth token refresh flow.
|
||
// If after both steps the server still responds with 401 we let the error bubble to the user.
|
||
//
|
||
// For external auth sources, UnauthorizedRecovery retries once by asking the
|
||
// configured provider to refresh and caching the returned auth through the same
|
||
// path used by other auth sources.
|
||
pub struct UnauthorizedRecovery {
|
||
manager: Arc<AuthManager>,
|
||
step: UnauthorizedRecoveryStep,
|
||
expected_account_id: Option<String>,
|
||
mode: UnauthorizedRecoveryMode,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub struct UnauthorizedRecoveryStepResult {
|
||
auth_state_changed: Option<bool>,
|
||
}
|
||
|
||
impl UnauthorizedRecoveryStepResult {
|
||
pub fn auth_state_changed(&self) -> Option<bool> {
|
||
self.auth_state_changed
|
||
}
|
||
}
|
||
|
||
impl UnauthorizedRecovery {
|
||
fn new(manager: Arc<AuthManager>) -> Self {
|
||
let cached_auth = manager.auth_cached();
|
||
let expected_account_id = cached_auth.as_ref().and_then(CodexAuth::get_account_id);
|
||
let mode = if manager.has_external_auth() {
|
||
UnauthorizedRecoveryMode::External
|
||
} else {
|
||
UnauthorizedRecoveryMode::Managed
|
||
};
|
||
let step = match mode {
|
||
UnauthorizedRecoveryMode::Managed => UnauthorizedRecoveryStep::Reload,
|
||
UnauthorizedRecoveryMode::External => UnauthorizedRecoveryStep::ExternalRefresh,
|
||
};
|
||
Self {
|
||
manager,
|
||
step,
|
||
expected_account_id,
|
||
mode,
|
||
}
|
||
}
|
||
|
||
pub fn has_next(&self) -> bool {
|
||
if self.manager.has_refreshable_external_auth() {
|
||
return !matches!(self.step, UnauthorizedRecoveryStep::Done);
|
||
}
|
||
|
||
if !self
|
||
.manager
|
||
.auth_cached()
|
||
.as_ref()
|
||
.is_some_and(CodexAuth::supports_unauthorized_recovery)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if self.mode == UnauthorizedRecoveryMode::External && !self.manager.has_external_auth() {
|
||
return false;
|
||
}
|
||
|
||
!matches!(self.step, UnauthorizedRecoveryStep::Done)
|
||
}
|
||
|
||
pub fn unavailable_reason(&self) -> &'static str {
|
||
if self.manager.has_refreshable_external_auth() {
|
||
return if matches!(self.step, UnauthorizedRecoveryStep::Done) {
|
||
"recovery_exhausted"
|
||
} else {
|
||
"ready"
|
||
};
|
||
}
|
||
|
||
if self
|
||
.manager
|
||
.auth_cached()
|
||
.as_ref()
|
||
.is_some_and(CodexAuth::is_personal_access_token_auth)
|
||
{
|
||
return "not_refreshable_auth";
|
||
}
|
||
|
||
if !self
|
||
.manager
|
||
.auth_cached()
|
||
.as_ref()
|
||
.is_some_and(CodexAuth::supports_unauthorized_recovery)
|
||
{
|
||
return "not_chatgpt_auth";
|
||
}
|
||
|
||
if self.mode == UnauthorizedRecoveryMode::External && !self.manager.has_external_auth() {
|
||
return "no_external_auth";
|
||
}
|
||
|
||
if matches!(self.step, UnauthorizedRecoveryStep::Done) {
|
||
return "recovery_exhausted";
|
||
}
|
||
|
||
"ready"
|
||
}
|
||
|
||
pub fn mode_name(&self) -> &'static str {
|
||
match self.mode {
|
||
UnauthorizedRecoveryMode::Managed => "managed",
|
||
UnauthorizedRecoveryMode::External => "external",
|
||
}
|
||
}
|
||
|
||
pub fn step_name(&self) -> &'static str {
|
||
match self.step {
|
||
UnauthorizedRecoveryStep::Reload => "reload",
|
||
UnauthorizedRecoveryStep::RefreshToken => "refresh_token",
|
||
UnauthorizedRecoveryStep::ExternalRefresh => "external_refresh",
|
||
UnauthorizedRecoveryStep::Done => "done",
|
||
}
|
||
}
|
||
|
||
pub async fn next(&mut self) -> Result<UnauthorizedRecoveryStepResult, RefreshTokenError> {
|
||
if !self.has_next() {
|
||
return Err(RefreshTokenError::Permanent(RefreshTokenFailedError::new(
|
||
RefreshTokenFailedReason::Other,
|
||
"No more recovery steps available.",
|
||
)));
|
||
}
|
||
|
||
match self.step {
|
||
UnauthorizedRecoveryStep::Reload => {
|
||
match self
|
||
.manager
|
||
.reload_if_account_id_matches(self.expected_account_id.as_deref())
|
||
.await
|
||
{
|
||
ReloadOutcome::ReloadedChanged => {
|
||
self.step = UnauthorizedRecoveryStep::RefreshToken;
|
||
return Ok(UnauthorizedRecoveryStepResult {
|
||
auth_state_changed: Some(true),
|
||
});
|
||
}
|
||
ReloadOutcome::ReloadedNoChange => {
|
||
self.step = UnauthorizedRecoveryStep::RefreshToken;
|
||
return Ok(UnauthorizedRecoveryStepResult {
|
||
auth_state_changed: Some(false),
|
||
});
|
||
}
|
||
ReloadOutcome::Skipped => {
|
||
self.step = UnauthorizedRecoveryStep::Done;
|
||
return Err(RefreshTokenError::Permanent(RefreshTokenFailedError::new(
|
||
RefreshTokenFailedReason::Other,
|
||
REFRESH_TOKEN_ACCOUNT_MISMATCH_MESSAGE.to_string(),
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
UnauthorizedRecoveryStep::RefreshToken => {
|
||
self.manager
|
||
.refresh_token_from_authority_with_reason(RefreshReason::UnauthorizedRecovery)
|
||
.await?;
|
||
self.step = UnauthorizedRecoveryStep::Done;
|
||
return Ok(UnauthorizedRecoveryStepResult {
|
||
auth_state_changed: Some(true),
|
||
});
|
||
}
|
||
UnauthorizedRecoveryStep::ExternalRefresh => {
|
||
self.manager
|
||
.refresh_token_from_authority_with_reason(RefreshReason::UnauthorizedRecovery)
|
||
.await?;
|
||
self.step = UnauthorizedRecoveryStep::Done;
|
||
return Ok(UnauthorizedRecoveryStepResult {
|
||
auth_state_changed: Some(true),
|
||
});
|
||
}
|
||
UnauthorizedRecoveryStep::Done => {}
|
||
}
|
||
Ok(UnauthorizedRecoveryStepResult {
|
||
auth_state_changed: None,
|
||
})
|
||
}
|
||
}
|
||
|
||
/// Central manager providing a single source of truth for stored
|
||
/// authentication data. It loads once (or on preference change) and then
|
||
/// hands out cloned `CodexAuth` values so the rest of the program has a
|
||
/// consistent snapshot.
|
||
///
|
||
/// External modifications to stored credentials will NOT be observed until
|
||
/// `reload()` is called explicitly. This matches the design goal of avoiding
|
||
/// different parts of the program seeing inconsistent auth data mid‑run.
|
||
pub struct AuthManager {
|
||
codex_home: PathBuf,
|
||
inner: RwLock<CachedAuth>,
|
||
auth_change_tx: watch::Sender<u64>,
|
||
auth_change_state_tx: watch::Sender<AuthChangeState>,
|
||
workspace_routing_resolver: OnceLock<Weak<dyn WorkspaceRoutingResolver>>,
|
||
enable_codex_api_key_env: bool,
|
||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||
keyring_backend_kind: AuthKeyringBackendKind,
|
||
forced_login_method: Option<ForcedLoginMethod>,
|
||
forced_chatgpt_workspace_id: RwLock<Option<Vec<String>>>,
|
||
managed_auth_policy: ManagedAuthPolicy,
|
||
chatgpt_base_url: Option<String>,
|
||
agent_identity_authapi_base_url: Option<String>,
|
||
refresh_lock: Semaphore,
|
||
agent_identity_lock: Semaphore,
|
||
agent_identity_bootstrap_cooldown: Mutex<AgentIdentityBootstrapCooldown>,
|
||
external_auth: RwLock<Option<Arc<dyn ExternalAuth>>>,
|
||
workload_identity_selected: bool,
|
||
auth_route_config: AuthRouteConfig,
|
||
}
|
||
|
||
/// Configuration view required to construct a shared [`AuthManager`].
|
||
///
|
||
/// Implementations should return the auth-related config values for the
|
||
/// already-resolved runtime configuration. The primary implementation is
|
||
/// `codex_core::config::Config`, but this trait keeps `codex-login` independent
|
||
/// from `codex-core`.
|
||
pub trait AuthManagerConfig {
|
||
/// Returns the Codex home directory used for auth storage.
|
||
fn codex_home(&self) -> PathBuf;
|
||
|
||
/// Returns the CLI auth credential storage mode for auth loading.
|
||
fn cli_auth_credentials_store_mode(&self) -> AuthCredentialsStoreMode;
|
||
|
||
/// Returns the backend to use when CLI auth keyring storage is selected.
|
||
fn auth_keyring_backend_kind(&self) -> AuthKeyringBackendKind;
|
||
|
||
/// Returns the resolved login-method restriction, if any.
|
||
fn forced_login_method(&self) -> Option<ForcedLoginMethod>;
|
||
|
||
/// Returns the workspace IDs that ChatGPT auth should be restricted to, if any.
|
||
fn forced_chatgpt_workspace_id(&self) -> Option<Vec<String>>;
|
||
|
||
/// Returns administrator-managed authentication restrictions.
|
||
fn managed_auth_policy(&self) -> ManagedAuthPolicy;
|
||
|
||
/// Returns the ChatGPT backend base URL used for first-party backend authorization.
|
||
fn chatgpt_base_url(&self) -> String;
|
||
|
||
/// Returns route-selection settings for auth-owned clients.
|
||
fn auth_route_config(&self) -> AuthRouteConfig;
|
||
}
|
||
|
||
/// Runtime storage and network policy shared by independent credential managers.
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct AuthRuntimeConfig {
|
||
pub codex_home: PathBuf,
|
||
pub auth_route_config: AuthRouteConfig,
|
||
}
|
||
|
||
impl Debug for AuthManager {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
f.debug_struct("AuthManager")
|
||
.field("codex_home", &self.codex_home)
|
||
.field("inner", &self.inner)
|
||
.field("enable_codex_api_key_env", &self.enable_codex_api_key_env)
|
||
.field(
|
||
"auth_credentials_store_mode",
|
||
&self.auth_credentials_store_mode,
|
||
)
|
||
.field("keyring_backend_kind", &self.keyring_backend_kind)
|
||
.field("forced_login_method", &self.forced_login_method)
|
||
.field(
|
||
"forced_chatgpt_workspace_id",
|
||
&self.forced_chatgpt_workspace_id,
|
||
)
|
||
.field("managed_auth_policy", &self.managed_auth_policy)
|
||
.field("chatgpt_base_url", &self.chatgpt_base_url)
|
||
.field("auth_route_config", &self.auth_route_config)
|
||
.field("has_external_auth", &self.has_external_auth())
|
||
.field(
|
||
"workload_identity_selected",
|
||
&self.workload_identity_selected,
|
||
)
|
||
.finish_non_exhaustive()
|
||
}
|
||
}
|
||
|
||
fn default_agent_identity_authapi_base_url() -> Option<String> {
|
||
agent_identity_authapi_base_url(/*chatgpt_base_url*/ None).ok()
|
||
}
|
||
|
||
impl AuthManager {
|
||
/// Returns the application policy associated with this account owner.
|
||
pub fn application_network_policy(&self) -> codex_http_client::NetworkPolicy {
|
||
self.auth_route_config.application_network_policy().clone()
|
||
}
|
||
|
||
/// Creates content clients bound to the account currently owned by this manager.
|
||
pub fn http_client_factory(&self) -> HttpClientFactory {
|
||
self.auth_route_config
|
||
.http_client_factory()
|
||
.clone()
|
||
.with_network_policy(self.application_network_policy().for_current_account())
|
||
}
|
||
/// Create a new manager loading the initial auth using the provided
|
||
/// preferred auth method. Errors loading auth are swallowed; `auth()` will
|
||
/// simply return `None` in that case so callers can treat it as an
|
||
/// unauthenticated state.
|
||
pub async fn new(
|
||
codex_home: PathBuf,
|
||
enable_codex_api_key_env: bool,
|
||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||
forced_chatgpt_workspace_id: Option<Vec<String>>,
|
||
chatgpt_base_url: Option<String>,
|
||
keyring_backend_kind: AuthKeyringBackendKind,
|
||
auth_route_config: AuthRouteConfig,
|
||
) -> Self {
|
||
Self::new_from_auth_config(
|
||
AuthConfig {
|
||
codex_home,
|
||
auth_credentials_store_mode,
|
||
keyring_backend_kind,
|
||
forced_login_method: None,
|
||
chatgpt_base_url,
|
||
forced_chatgpt_workspace_id,
|
||
managed_auth_policy: ManagedAuthPolicy::default(),
|
||
auth_route_config,
|
||
},
|
||
enable_codex_api_key_env,
|
||
)
|
||
.await
|
||
}
|
||
|
||
async fn new_from_auth_config(auth_config: AuthConfig, enable_codex_api_key_env: bool) -> Self {
|
||
let managed_auth = auth_config
|
||
.load_auth(enable_codex_api_key_env)
|
||
.await
|
||
.ok()
|
||
.flatten();
|
||
let AuthConfig {
|
||
codex_home,
|
||
auth_credentials_store_mode,
|
||
keyring_backend_kind,
|
||
forced_login_method,
|
||
chatgpt_base_url,
|
||
forced_chatgpt_workspace_id,
|
||
managed_auth_policy,
|
||
auth_route_config,
|
||
} = auth_config;
|
||
let agent_identity_authapi_base_url =
|
||
agent_identity_authapi_base_url(chatgpt_base_url.as_deref()).ok();
|
||
let (auth_change_tx, _auth_change_rx) = watch::channel(0);
|
||
Self {
|
||
codex_home,
|
||
inner: RwLock::new(CachedAuth {
|
||
auth: managed_auth,
|
||
permanent_refresh_failure: None,
|
||
}),
|
||
auth_change_tx,
|
||
auth_change_state_tx: watch::channel(AuthChangeState::default()).0,
|
||
workspace_routing_resolver: OnceLock::new(),
|
||
enable_codex_api_key_env,
|
||
auth_credentials_store_mode,
|
||
keyring_backend_kind,
|
||
forced_login_method,
|
||
forced_chatgpt_workspace_id: RwLock::new(forced_chatgpt_workspace_id),
|
||
managed_auth_policy,
|
||
chatgpt_base_url,
|
||
agent_identity_authapi_base_url,
|
||
refresh_lock: Semaphore::new(/*permits*/ 1),
|
||
agent_identity_lock: Semaphore::new(/*permits*/ 1),
|
||
agent_identity_bootstrap_cooldown: Mutex::default(),
|
||
external_auth: RwLock::new(None),
|
||
workload_identity_selected: false,
|
||
auth_route_config,
|
||
}
|
||
}
|
||
|
||
/// Create an AuthManager with a specific CodexAuth, for testing only.
|
||
pub fn from_auth_for_testing(auth: CodexAuth) -> Arc<Self> {
|
||
Self::from_optional_auth_for_testing(Some(auth))
|
||
}
|
||
|
||
/// Create an AuthManager with an optional CodexAuth, for testing only.
|
||
pub(crate) fn from_optional_auth_for_testing(auth: Option<CodexAuth>) -> Arc<Self> {
|
||
let cached = CachedAuth {
|
||
auth,
|
||
permanent_refresh_failure: None,
|
||
};
|
||
let (auth_change_tx, _auth_change_rx) = watch::channel(0);
|
||
|
||
Arc::new(Self {
|
||
codex_home: PathBuf::from("non-existent"),
|
||
inner: RwLock::new(cached),
|
||
auth_change_tx,
|
||
auth_change_state_tx: watch::channel(AuthChangeState::default()).0,
|
||
workspace_routing_resolver: OnceLock::new(),
|
||
enable_codex_api_key_env: false,
|
||
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
|
||
keyring_backend_kind: AuthKeyringBackendKind::default(),
|
||
forced_login_method: None,
|
||
forced_chatgpt_workspace_id: RwLock::new(None),
|
||
managed_auth_policy: ManagedAuthPolicy::default(),
|
||
chatgpt_base_url: None,
|
||
agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(),
|
||
refresh_lock: Semaphore::new(/*permits*/ 1),
|
||
agent_identity_lock: Semaphore::new(/*permits*/ 1),
|
||
agent_identity_bootstrap_cooldown: Mutex::default(),
|
||
external_auth: RwLock::new(None),
|
||
workload_identity_selected: false,
|
||
auth_route_config: crate::test_support::transport_default_auth_route_config(),
|
||
})
|
||
}
|
||
|
||
/// Create an AuthManager with a specific CodexAuth and codex home, for testing only.
|
||
pub fn from_auth_for_testing_with_home(auth: CodexAuth, codex_home: PathBuf) -> Arc<Self> {
|
||
let cached = CachedAuth {
|
||
auth: Some(auth),
|
||
permanent_refresh_failure: None,
|
||
};
|
||
let (auth_change_tx, _auth_change_rx) = watch::channel(0);
|
||
Arc::new(Self {
|
||
codex_home,
|
||
inner: RwLock::new(cached),
|
||
auth_change_tx,
|
||
auth_change_state_tx: watch::channel(AuthChangeState::default()).0,
|
||
workspace_routing_resolver: OnceLock::new(),
|
||
enable_codex_api_key_env: false,
|
||
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
|
||
keyring_backend_kind: AuthKeyringBackendKind::default(),
|
||
forced_login_method: None,
|
||
forced_chatgpt_workspace_id: RwLock::new(None),
|
||
managed_auth_policy: ManagedAuthPolicy::default(),
|
||
chatgpt_base_url: None,
|
||
agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(),
|
||
refresh_lock: Semaphore::new(/*permits*/ 1),
|
||
agent_identity_lock: Semaphore::new(/*permits*/ 1),
|
||
agent_identity_bootstrap_cooldown: Mutex::default(),
|
||
external_auth: RwLock::new(None),
|
||
workload_identity_selected: false,
|
||
auth_route_config: crate::test_support::transport_default_auth_route_config(),
|
||
})
|
||
}
|
||
|
||
/// Create an AuthManager with a specific CodexAuth and Agent Identity AuthAPI base URL, for testing only.
|
||
#[doc(hidden)]
|
||
pub fn from_auth_for_testing_with_agent_identity_authapi_base_url(
|
||
auth: CodexAuth,
|
||
agent_identity_authapi_base_url: String,
|
||
) -> Arc<Self> {
|
||
let cached = CachedAuth {
|
||
auth: Some(auth),
|
||
permanent_refresh_failure: None,
|
||
};
|
||
let (auth_change_tx, _auth_change_rx) = watch::channel(0);
|
||
Arc::new(Self {
|
||
codex_home: PathBuf::from("non-existent"),
|
||
inner: RwLock::new(cached),
|
||
auth_change_tx,
|
||
auth_change_state_tx: watch::channel(AuthChangeState::default()).0,
|
||
workspace_routing_resolver: OnceLock::new(),
|
||
enable_codex_api_key_env: false,
|
||
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
|
||
keyring_backend_kind: AuthKeyringBackendKind::default(),
|
||
forced_login_method: None,
|
||
forced_chatgpt_workspace_id: RwLock::new(None),
|
||
managed_auth_policy: ManagedAuthPolicy::default(),
|
||
chatgpt_base_url: None,
|
||
agent_identity_authapi_base_url: Some(
|
||
agent_identity_authapi_base_url
|
||
.trim_end_matches('/')
|
||
.to_string(),
|
||
),
|
||
refresh_lock: Semaphore::new(/*permits*/ 1),
|
||
agent_identity_lock: Semaphore::new(/*permits*/ 1),
|
||
agent_identity_bootstrap_cooldown: Mutex::default(),
|
||
external_auth: RwLock::new(None),
|
||
workload_identity_selected: false,
|
||
auth_route_config: crate::test_support::transport_default_auth_route_config(),
|
||
})
|
||
}
|
||
|
||
pub fn external_bearer_only(config: ModelProviderAuthInfo) -> Arc<Self> {
|
||
let (auth_change_tx, _auth_change_rx) = watch::channel(0);
|
||
Arc::new(Self {
|
||
codex_home: PathBuf::from("non-existent"),
|
||
inner: RwLock::new(CachedAuth {
|
||
auth: None,
|
||
permanent_refresh_failure: None,
|
||
}),
|
||
auth_change_tx,
|
||
auth_change_state_tx: watch::channel(AuthChangeState::default()).0,
|
||
workspace_routing_resolver: OnceLock::new(),
|
||
enable_codex_api_key_env: false,
|
||
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
|
||
keyring_backend_kind: AuthKeyringBackendKind::default(),
|
||
forced_login_method: None,
|
||
forced_chatgpt_workspace_id: RwLock::new(None),
|
||
managed_auth_policy: ManagedAuthPolicy::default(),
|
||
chatgpt_base_url: None,
|
||
agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(),
|
||
refresh_lock: Semaphore::new(/*permits*/ 1),
|
||
agent_identity_lock: Semaphore::new(/*permits*/ 1),
|
||
agent_identity_bootstrap_cooldown: Mutex::default(),
|
||
external_auth: RwLock::new(Some(Arc::new(BearerTokenRefresher::new(config)))),
|
||
workload_identity_selected: false,
|
||
// External bearer auth refreshes by running the provider's command and never makes
|
||
// auth-owned HTTP requests, so this route is intentionally inert.
|
||
auth_route_config: AuthRouteConfig::from_http_client_factory(HttpClientFactory::new(
|
||
OutboundProxyPolicy::ReqwestDefault,
|
||
)),
|
||
})
|
||
}
|
||
|
||
/// Current cached auth (clone) without attempting a refresh.
|
||
pub fn auth_cached(&self) -> Option<CodexAuth> {
|
||
self.inner
|
||
.read()
|
||
.ok()
|
||
.and_then(|cached| cached.auth.clone())
|
||
}
|
||
|
||
/// Subscribes to cached auth changes that can affect request recovery.
|
||
pub fn auth_change_receiver(&self) -> watch::Receiver<u64> {
|
||
self.auth_change_tx.subscribe()
|
||
}
|
||
|
||
/// Subscribes to credential and owner revisions published together, including when changes coalesce.
|
||
pub fn auth_change_state_receiver(&self) -> watch::Receiver<AuthChangeState> {
|
||
self.auth_change_state_tx.subscribe()
|
||
}
|
||
|
||
pub fn refresh_failure_for_auth(&self, auth: &CodexAuth) -> Option<RefreshTokenFailedError> {
|
||
self.inner.read().ok().and_then(|cached| {
|
||
cached
|
||
.permanent_refresh_failure
|
||
.as_ref()
|
||
.filter(|failure| Self::auths_equal_for_refresh(Some(auth), Some(&failure.auth)))
|
||
.map(|failure| failure.error.clone())
|
||
})
|
||
}
|
||
|
||
/// Current cached auth (clone). May be `None` if not logged in or load failed.
|
||
/// For managed ChatGPT auth that needs a proactive refresh, first performs
|
||
/// a guarded reload and then refreshes only if the on-disk auth is unchanged.
|
||
#[instrument(level = "trace", skip_all)]
|
||
pub async fn auth(&self) -> Option<CodexAuth> {
|
||
if self.has_external_auth() {
|
||
self.reload().await;
|
||
return self.auth_cached();
|
||
}
|
||
|
||
let auth = self.auth_cached()?;
|
||
if let Some(reason) = Self::proactive_refresh_reason(&auth)
|
||
&& let Err(err) = self.refresh_token_with_reason(reason).await
|
||
{
|
||
tracing::error!("Failed to refresh token: {}", err);
|
||
return Some(auth);
|
||
}
|
||
self.auth_cached()
|
||
}
|
||
|
||
/// Refreshes auth, then captures credentials and their account-bound factory together.
|
||
/// The auth read lock prevents an identity change between the two snapshots.
|
||
pub async fn auth_with_http_client_factory(&self) -> Option<(CodexAuth, HttpClientFactory)> {
|
||
self.auth().await;
|
||
let cached = self.inner.read().ok()?;
|
||
Some((cached.auth.clone()?, self.http_client_factory()))
|
||
}
|
||
|
||
pub async fn agent_identity_auth(
|
||
&self,
|
||
policy: AgentIdentityAuthPolicy,
|
||
session_source: SessionSource,
|
||
) -> std::io::Result<Option<AgentIdentityAuth>> {
|
||
let Some(auth) = self.auth().await else {
|
||
return Ok(None);
|
||
};
|
||
if policy == AgentIdentityAuthPolicy::ChatGptAuth && matches!(auth, CodexAuth::Chatgpt(_)) {
|
||
let _bootstrap_permit = self
|
||
.agent_identity_lock
|
||
.acquire()
|
||
.await
|
||
.map_err(std::io::Error::other)?;
|
||
let effective_chatgpt_workspaces = self.effective_chatgpt_workspaces();
|
||
let cooldown_key = ManagedChatGptAgentIdentityBinding::from_auth(
|
||
&auth,
|
||
effective_chatgpt_workspaces.clone(),
|
||
)
|
||
.and_then(|binding| {
|
||
self.agent_identity_authapi_base_url
|
||
.as_ref()
|
||
.map(|base_url| (binding.account_id, base_url.clone()))
|
||
});
|
||
if let Some((account_id, authapi_base_url)) = cooldown_key.as_ref()
|
||
&& let Ok(mut cooldown) = self.agent_identity_bootstrap_cooldown.lock()
|
||
&& let Some(error) =
|
||
cooldown.error_for(account_id, authapi_base_url, Instant::now())
|
||
{
|
||
tracing::warn!("agent identity bootstrap retry suppressed during shared cooldown");
|
||
return Err(std::io::Error::other(error));
|
||
}
|
||
|
||
let result = auth
|
||
.agent_identity_auth(
|
||
policy,
|
||
self.agent_identity_authapi_base_url.as_deref(),
|
||
effective_chatgpt_workspaces,
|
||
&self.auth_route_config,
|
||
session_source,
|
||
)
|
||
.await;
|
||
if let Ok(mut cooldown) = self.agent_identity_bootstrap_cooldown.lock() {
|
||
if let (Err(err), Some((account_id, authapi_base_url))) = (&result, cooldown_key)
|
||
&& let Some(error) = AgentIdentityAuthError::bootstrap_unavailable(err).cloned()
|
||
{
|
||
cooldown.record_failure(account_id, authapi_base_url, error, Instant::now());
|
||
} else {
|
||
cooldown.clear();
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
auth.agent_identity_auth(
|
||
policy,
|
||
self.agent_identity_authapi_base_url.as_deref(),
|
||
self.effective_chatgpt_workspaces(),
|
||
&self.auth_route_config,
|
||
session_source,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Reloads auth from the active source. Returns whether the auth value changed.
|
||
pub async fn reload(&self) -> bool {
|
||
tracing::info!("Reloading auth");
|
||
let new_auth = self.load_auth().await;
|
||
self.set_cached_auth(new_auth)
|
||
}
|
||
|
||
async fn reload_if_account_id_matches(
|
||
&self,
|
||
expected_account_id: Option<&str>,
|
||
) -> ReloadOutcome {
|
||
let expected_account_id = match expected_account_id {
|
||
Some(account_id) => account_id,
|
||
None => {
|
||
tracing::info!("Skipping auth reload because no account id is available.");
|
||
return ReloadOutcome::Skipped;
|
||
}
|
||
};
|
||
|
||
let new_auth = self.load_auth().await;
|
||
let new_account_id = new_auth.as_ref().and_then(CodexAuth::get_account_id);
|
||
|
||
if new_account_id.as_deref() != Some(expected_account_id) {
|
||
let found_account_id = new_account_id.as_deref().unwrap_or("unknown");
|
||
tracing::info!(
|
||
"Skipping auth reload due to account id mismatch (expected: {expected_account_id}, found: {found_account_id})"
|
||
);
|
||
return ReloadOutcome::Skipped;
|
||
}
|
||
|
||
tracing::info!("Reloading auth for account {expected_account_id}");
|
||
let cached_before_reload = self.auth_cached();
|
||
let auth_changed =
|
||
!Self::auths_equal_for_refresh(cached_before_reload.as_ref(), new_auth.as_ref());
|
||
self.set_cached_auth(new_auth);
|
||
if auth_changed {
|
||
ReloadOutcome::ReloadedChanged
|
||
} else {
|
||
ReloadOutcome::ReloadedNoChange
|
||
}
|
||
}
|
||
|
||
fn auths_equal_for_refresh(a: Option<&CodexAuth>, b: Option<&CodexAuth>) -> bool {
|
||
match (a, b) {
|
||
(None, None) => true,
|
||
(Some(a), Some(b)) => match (a.api_auth_mode(), b.api_auth_mode()) {
|
||
(AuthMode::ApiKey, AuthMode::ApiKey) => a.api_key() == b.api_key(),
|
||
(AuthMode::Chatgpt, AuthMode::Chatgpt) => {
|
||
a.get_current_auth_json() == b.get_current_auth_json()
|
||
}
|
||
(AuthMode::ChatgptAuthTokens, AuthMode::ChatgptAuthTokens) => {
|
||
a.get_current_token_data() == b.get_current_token_data()
|
||
}
|
||
(AuthMode::Headers, AuthMode::Headers) => a == b,
|
||
(AuthMode::AgentIdentity, AuthMode::AgentIdentity) => match (a, b) {
|
||
(CodexAuth::AgentIdentity(a), CodexAuth::AgentIdentity(b)) => {
|
||
a.record() == b.record()
|
||
}
|
||
_ => false,
|
||
},
|
||
(AuthMode::PersonalAccessToken, AuthMode::PersonalAccessToken) => a == b,
|
||
(AuthMode::BedrockApiKey, AuthMode::BedrockApiKey) => a == b,
|
||
(AuthMode::BedrockAccessKeys, AuthMode::BedrockAccessKeys) => a == b,
|
||
_ => false,
|
||
},
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
fn auths_equal(a: Option<&CodexAuth>, b: Option<&CodexAuth>) -> bool {
|
||
match (a, b) {
|
||
(None, None) => true,
|
||
(Some(a), Some(b)) => a == b,
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// Records a permanent refresh failure only if the failed refresh was
|
||
/// attempted against the auth snapshot that is still cached.
|
||
fn record_permanent_refresh_failure_if_unchanged(
|
||
&self,
|
||
attempted_auth: &CodexAuth,
|
||
error: &RefreshTokenFailedError,
|
||
) {
|
||
if let Ok(mut guard) = self.inner.write() {
|
||
let current_auth_matches =
|
||
Self::auths_equal_for_refresh(Some(attempted_auth), guard.auth.as_ref());
|
||
if current_auth_matches {
|
||
guard.permanent_refresh_failure = Some(AuthScopedRefreshFailure {
|
||
auth: attempted_auth.clone(),
|
||
error: error.clone(),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn load_auth(&self) -> Option<CodexAuth> {
|
||
if let Some(external_auth) = self.external_auth_provider() {
|
||
let cached_auth = self.auth_cached();
|
||
if cached_auth
|
||
.as_ref()
|
||
.is_some_and(|auth| self.refresh_failure_for_auth(auth).is_some())
|
||
{
|
||
return cached_auth;
|
||
}
|
||
return match self.resolve_external_auth(external_auth.as_ref()).await {
|
||
Ok(auth) => Some(auth),
|
||
Err(err) => {
|
||
tracing::error!("Failed to resolve external auth: {err}");
|
||
match err {
|
||
RefreshTokenError::Permanent(error) => {
|
||
if let Some(auth) = cached_auth.as_ref() {
|
||
self.record_permanent_refresh_failure_if_unchanged(auth, &error);
|
||
}
|
||
cached_auth
|
||
}
|
||
RefreshTokenError::Transient(_) => None,
|
||
RefreshTokenError::Policy(_) => cached_auth,
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
let allowed_login_methods = self.allowed_login_methods();
|
||
let effective_chatgpt_workspaces = self.effective_chatgpt_workspaces();
|
||
load_auth(
|
||
&self.codex_home,
|
||
self.enable_codex_api_key_env,
|
||
self.auth_credentials_store_mode,
|
||
Some(&allowed_login_methods),
|
||
effective_chatgpt_workspaces.as_deref(),
|
||
self.chatgpt_base_url.as_deref(),
|
||
self.keyring_backend_kind,
|
||
self.agent_identity_authapi_base_url.as_deref(),
|
||
&self.auth_route_config,
|
||
)
|
||
.await
|
||
.ok()
|
||
.flatten()
|
||
.filter(|auth| {
|
||
validate_auth_restrictions(
|
||
Some(&allowed_login_methods),
|
||
effective_chatgpt_workspaces.as_deref(),
|
||
auth,
|
||
)
|
||
.is_ok()
|
||
})
|
||
}
|
||
|
||
fn set_cached_auth(&self, new_auth: Option<CodexAuth>) -> bool {
|
||
if let Ok(mut guard) = self.inner.write() {
|
||
let previous = guard.auth.as_ref();
|
||
let changed = !AuthManager::auths_equal(previous, new_auth.as_ref());
|
||
let auth_changed_for_refresh =
|
||
!Self::auths_equal_for_refresh(previous, new_auth.as_ref());
|
||
let owner_changed =
|
||
auth_changed_for_refresh && !same_owner(previous, new_auth.as_ref());
|
||
if owner_changed {
|
||
self.auth_route_config
|
||
.application_network_policy()
|
||
.invalidate();
|
||
}
|
||
if auth_changed_for_refresh {
|
||
guard.permanent_refresh_failure = None;
|
||
}
|
||
tracing::info!("Reloaded auth, changed: {changed}");
|
||
guard.auth = new_auth;
|
||
if auth_changed_for_refresh {
|
||
self.auth_change_state_tx.send_modify(|state| {
|
||
state.generation += 1;
|
||
if owner_changed {
|
||
state.owner_generation += 1;
|
||
}
|
||
});
|
||
self.auth_change_tx.send_modify(|revision| *revision += 1);
|
||
}
|
||
changed
|
||
} else {
|
||
false
|
||
}
|
||
}
|
||
|
||
pub async fn set_external_auth(
|
||
&self,
|
||
external_auth: Arc<dyn ExternalAuth>,
|
||
) -> Result<(), RefreshTokenError> {
|
||
if self.workload_identity_selected {
|
||
return Err(permanent_external_auth_error(
|
||
"workload identity auth cannot be replaced at runtime",
|
||
));
|
||
}
|
||
self.install_external_auth(external_auth).await
|
||
}
|
||
|
||
async fn install_external_auth(
|
||
&self,
|
||
external_auth: Arc<dyn ExternalAuth>,
|
||
) -> Result<(), RefreshTokenError> {
|
||
let auth = self.resolve_external_auth(external_auth.as_ref()).await?;
|
||
let mut external_auth_slot = self.external_auth.write().map_err(|_| {
|
||
RefreshTokenError::Transient(std::io::Error::other("external auth lock is poisoned"))
|
||
})?;
|
||
*external_auth_slot = Some(external_auth);
|
||
drop(external_auth_slot);
|
||
if let Ok(mut guard) = self.inner.write() {
|
||
guard.permanent_refresh_failure = None;
|
||
}
|
||
self.commit_external_auth(auth)
|
||
}
|
||
|
||
pub fn clear_external_auth(&self) {
|
||
if self.workload_identity_selected {
|
||
return;
|
||
}
|
||
if let Ok(mut external_auth) = self.external_auth.write()
|
||
&& external_auth.take().is_some()
|
||
{
|
||
self.set_cached_auth(/*new_auth*/ None);
|
||
}
|
||
}
|
||
|
||
pub fn set_forced_chatgpt_workspace_id(&self, workspace_id: Option<Vec<String>>) {
|
||
if let Ok(mut guard) = self.forced_chatgpt_workspace_id.write()
|
||
&& *guard != workspace_id
|
||
{
|
||
*guard = workspace_id;
|
||
}
|
||
}
|
||
|
||
pub fn forced_chatgpt_workspace_id(&self) -> Option<Vec<String>> {
|
||
self.forced_chatgpt_workspace_id
|
||
.read()
|
||
.ok()
|
||
.and_then(|guard| guard.clone())
|
||
}
|
||
|
||
pub fn effective_chatgpt_workspaces(&self) -> Option<Vec<String>> {
|
||
self.managed_auth_policy
|
||
.effective_chatgpt_workspaces(self.forced_chatgpt_workspace_id().as_deref())
|
||
}
|
||
|
||
pub fn is_login_method_allowed(&self, method: ForcedLoginMethod) -> bool {
|
||
self.managed_auth_policy.allows_login_method(
|
||
method,
|
||
self.forced_login_method,
|
||
self.forced_chatgpt_workspace_id().as_deref(),
|
||
)
|
||
}
|
||
|
||
/// Returns the login methods permitted by the current effective authentication policy.
|
||
pub fn allowed_login_methods(&self) -> Vec<ForcedLoginMethod> {
|
||
self.managed_auth_policy.allowed_login_methods(
|
||
self.forced_login_method,
|
||
self.forced_chatgpt_workspace_id().as_deref(),
|
||
)
|
||
}
|
||
|
||
pub fn has_external_auth(&self) -> bool {
|
||
self.external_auth_provider().is_some()
|
||
}
|
||
|
||
pub fn is_workload_identity_selected(&self) -> bool {
|
||
self.workload_identity_selected
|
||
}
|
||
|
||
pub fn is_external_chatgpt_auth_active(&self) -> bool {
|
||
self.auth_cached()
|
||
.as_ref()
|
||
.is_some_and(CodexAuth::is_external_chatgpt_tokens)
|
||
}
|
||
|
||
pub fn codex_api_key_env_enabled(&self) -> bool {
|
||
self.enable_codex_api_key_env
|
||
}
|
||
|
||
/// Returns policy only; independent credential managers own their own state and lifecycle.
|
||
pub fn runtime_config(&self) -> AuthRuntimeConfig {
|
||
AuthRuntimeConfig {
|
||
codex_home: self.codex_home.clone(),
|
||
auth_route_config: self.auth_route_config.clone(),
|
||
}
|
||
}
|
||
|
||
/// Convenience constructor returning an `Arc` wrapper.
|
||
pub async fn shared(
|
||
codex_home: PathBuf,
|
||
enable_codex_api_key_env: bool,
|
||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||
forced_chatgpt_workspace_id: Option<Vec<String>>,
|
||
chatgpt_base_url: Option<String>,
|
||
keyring_backend_kind: AuthKeyringBackendKind,
|
||
auth_route_config: AuthRouteConfig,
|
||
) -> Arc<Self> {
|
||
Arc::new(
|
||
Self::new(
|
||
codex_home,
|
||
enable_codex_api_key_env,
|
||
auth_credentials_store_mode,
|
||
forced_chatgpt_workspace_id,
|
||
chatgpt_base_url,
|
||
keyring_backend_kind,
|
||
auth_route_config,
|
||
)
|
||
.await,
|
||
)
|
||
}
|
||
|
||
/// Builds a shared manager and activates process-configured workload identity when selected.
|
||
pub async fn shared_from_config(
|
||
config: &impl AuthManagerConfig,
|
||
enable_codex_api_key_env: bool,
|
||
) -> Result<Arc<Self>, AuthManagerInitializationError> {
|
||
Self::shared_from_auth_config(auth_config_from(config), enable_codex_api_key_env).await
|
||
}
|
||
|
||
/// Activates workload identity against an auth config resolved before full runtime config.
|
||
pub async fn shared_from_auth_config(
|
||
auth_config: AuthConfig,
|
||
enable_codex_api_key_env: bool,
|
||
) -> Result<Arc<Self>, AuthManagerInitializationError> {
|
||
let external_auth = WorkloadIdentityExternalAuth::from_process_config(&auth_config)?;
|
||
let mut manager = Self::new_from_auth_config(auth_config, enable_codex_api_key_env).await;
|
||
manager.workload_identity_selected = external_auth.is_some();
|
||
let manager = Arc::new(manager);
|
||
if let Some(external_auth) = external_auth {
|
||
manager
|
||
.install_external_auth(Arc::new(external_auth))
|
||
.await?;
|
||
}
|
||
Ok(manager)
|
||
}
|
||
|
||
pub fn unauthorized_recovery(self: &Arc<Self>) -> UnauthorizedRecovery {
|
||
UnauthorizedRecovery::new(Arc::clone(self))
|
||
}
|
||
|
||
fn external_auth_provider(&self) -> Option<Arc<dyn ExternalAuth>> {
|
||
self.external_auth
|
||
.read()
|
||
.ok()
|
||
.and_then(|external_auth| external_auth.clone())
|
||
}
|
||
|
||
fn has_refreshable_external_auth(&self) -> bool {
|
||
self.has_external_auth()
|
||
&& self
|
||
.auth_cached()
|
||
.as_ref()
|
||
.is_none_or(|auth| auth.is_api_key_auth() || auth.supports_unauthorized_recovery())
|
||
}
|
||
|
||
async fn resolve_external_auth(
|
||
&self,
|
||
external_auth: &dyn ExternalAuth,
|
||
) -> Result<CodexAuth, RefreshTokenError> {
|
||
let auth = external_auth
|
||
.resolve()
|
||
.await
|
||
.map_err(|error| external_auth.classify_error(error))?;
|
||
self.validate_external_auth(&auth, external_auth)?;
|
||
Ok(auth)
|
||
}
|
||
|
||
/// Attempt to refresh the token by first performing a guarded reload from
|
||
/// the active auth source. If the loaded token differs from the cached token,
|
||
/// we can assume that the source already refreshed it. Otherwise, ask the
|
||
/// token authority to refresh.
|
||
pub async fn refresh_token(&self) -> Result<(), RefreshTokenError> {
|
||
self.refresh_token_with_reason(RefreshReason::Forced).await
|
||
}
|
||
|
||
async fn refresh_token_with_reason(
|
||
&self,
|
||
reason: RefreshReason,
|
||
) -> Result<(), RefreshTokenError> {
|
||
let _refresh_guard = self.refresh_lock.acquire().await.map_err(|_| {
|
||
RefreshTokenError::Permanent(RefreshTokenFailedError::new(
|
||
RefreshTokenFailedReason::Other,
|
||
REFRESH_TOKEN_UNKNOWN_MESSAGE.to_string(),
|
||
))
|
||
})?;
|
||
let auth_before_reload = self.auth_cached();
|
||
if auth_before_reload
|
||
.as_ref()
|
||
.is_some_and(|auth| auth.is_api_key_auth() || auth.is_personal_access_token_auth())
|
||
{
|
||
return Ok(());
|
||
}
|
||
let expected_account_id = auth_before_reload
|
||
.as_ref()
|
||
.and_then(CodexAuth::get_account_id);
|
||
|
||
match self
|
||
.reload_if_account_id_matches(expected_account_id.as_deref())
|
||
.await
|
||
{
|
||
ReloadOutcome::ReloadedChanged => {
|
||
tracing::info!("Skipping token refresh because auth changed after guarded reload.");
|
||
Ok(())
|
||
}
|
||
ReloadOutcome::ReloadedNoChange => self.refresh_token_from_authority_impl(reason).await,
|
||
ReloadOutcome::Skipped => {
|
||
Err(RefreshTokenError::Permanent(RefreshTokenFailedError::new(
|
||
RefreshTokenFailedReason::Other,
|
||
REFRESH_TOKEN_ACCOUNT_MISMATCH_MESSAGE.to_string(),
|
||
)))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Attempt to refresh the current auth token from the authority that issued
|
||
/// it and update the shared cache. If the token refresh fails, returns the
|
||
/// error to the caller.
|
||
pub async fn refresh_token_from_authority(&self) -> Result<(), RefreshTokenError> {
|
||
self.refresh_token_from_authority_with_reason(RefreshReason::Forced)
|
||
.await
|
||
}
|
||
|
||
async fn refresh_token_from_authority_with_reason(
|
||
&self,
|
||
reason: RefreshReason,
|
||
) -> Result<(), RefreshTokenError> {
|
||
let _refresh_guard = self.refresh_lock.acquire().await.map_err(|_| {
|
||
RefreshTokenError::Permanent(RefreshTokenFailedError::new(
|
||
RefreshTokenFailedReason::Other,
|
||
REFRESH_TOKEN_UNKNOWN_MESSAGE.to_string(),
|
||
))
|
||
})?;
|
||
self.refresh_token_from_authority_impl(reason).await
|
||
}
|
||
|
||
async fn refresh_token_from_authority_impl(
|
||
&self,
|
||
reason: RefreshReason,
|
||
) -> Result<(), RefreshTokenError> {
|
||
tracing::info!("Refreshing token");
|
||
|
||
let attempted_auth = self.auth_cached();
|
||
if let Some(error) = attempted_auth
|
||
.as_ref()
|
||
.and_then(|auth| self.refresh_failure_for_auth(auth))
|
||
{
|
||
return Err(RefreshTokenError::Permanent(error));
|
||
}
|
||
|
||
let result = if self.has_external_auth() {
|
||
self.refresh_external_auth(ExternalAuthRefreshReason::Unauthorized)
|
||
.await
|
||
} else {
|
||
match attempted_auth.as_ref() {
|
||
Some(CodexAuth::Chatgpt(chatgpt_auth)) => {
|
||
let token_data = chatgpt_auth.current_token_data().ok_or_else(|| {
|
||
RefreshTokenError::Transient(std::io::Error::other(
|
||
"Token data is not available.",
|
||
))
|
||
})?;
|
||
self.refresh_and_persist_chatgpt_token(
|
||
chatgpt_auth,
|
||
token_data.refresh_token,
|
||
reason,
|
||
)
|
||
.await
|
||
}
|
||
Some(
|
||
CodexAuth::ApiKey(_)
|
||
| CodexAuth::ChatgptAuthTokens(_)
|
||
| CodexAuth::Headers(_)
|
||
| CodexAuth::AgentIdentity(_)
|
||
| CodexAuth::PersonalAccessToken(_)
|
||
| CodexAuth::BedrockApiKey(_)
|
||
| CodexAuth::BedrockAccessKeys(_),
|
||
)
|
||
| None => Ok(()),
|
||
}
|
||
};
|
||
if let Some(attempted_auth) = attempted_auth.as_ref()
|
||
&& let Err(RefreshTokenError::Permanent(error)) = &result
|
||
{
|
||
self.record_permanent_refresh_failure_if_unchanged(attempted_auth, error);
|
||
}
|
||
result
|
||
}
|
||
|
||
/// Log out by deleting credentials from the selected storage backend and the
|
||
/// ephemeral store. Returns `Ok(true)` if credentials were removed, `Ok(false)`
|
||
/// if none were present. On success, reloads the in-memory auth cache so
|
||
/// callers immediately observe the unauthenticated state.
|
||
pub async fn logout(&self) -> std::io::Result<bool> {
|
||
self.ensure_logout_allowed()?;
|
||
let removed = logout_all_stores(
|
||
&self.codex_home,
|
||
self.auth_credentials_store_mode,
|
||
self.keyring_backend_kind,
|
||
)?;
|
||
// Always reload to clear cached auth, even if no stored credentials were present.
|
||
self.clear_external_auth();
|
||
self.reload().await;
|
||
Ok(removed)
|
||
}
|
||
|
||
pub async fn logout_with_revoke(&self) -> std::io::Result<bool> {
|
||
self.ensure_logout_allowed()?;
|
||
let auth_dot_json = self
|
||
.auth_cached()
|
||
.and_then(|auth| auth.get_current_auth_json());
|
||
if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref(), &self.auth_route_config).await
|
||
{
|
||
tracing::warn!("failed to revoke auth tokens during logout: {err}");
|
||
}
|
||
let result = logout_all_stores(
|
||
&self.codex_home,
|
||
self.auth_credentials_store_mode,
|
||
self.keyring_backend_kind,
|
||
)?;
|
||
// Always reload to clear cached auth, even if no stored credentials were present.
|
||
self.clear_external_auth();
|
||
self.reload().await;
|
||
Ok(result)
|
||
}
|
||
|
||
fn ensure_logout_allowed(&self) -> std::io::Result<()> {
|
||
if self.workload_identity_selected {
|
||
return Err(std::io::Error::new(
|
||
std::io::ErrorKind::PermissionDenied,
|
||
"workload identity auth is managed by the host and cannot be logged out",
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Returns the precise kind of credentials backing the current authentication.
|
||
pub fn get_api_auth_mode(&self) -> Option<AuthMode> {
|
||
self.auth_cached().as_ref().map(CodexAuth::api_auth_mode)
|
||
}
|
||
|
||
/// Returns the effective backend auth mode for the current authentication.
|
||
pub fn auth_mode(&self) -> Option<AuthMode> {
|
||
self.auth_cached().as_ref().map(CodexAuth::auth_mode)
|
||
}
|
||
|
||
pub fn current_auth_uses_codex_backend(&self) -> bool {
|
||
self.get_api_auth_mode()
|
||
.is_some_and(AuthMode::uses_codex_backend)
|
||
}
|
||
|
||
fn proactive_refresh_reason(auth: &CodexAuth) -> Option<RefreshReason> {
|
||
let chatgpt_auth = match auth {
|
||
CodexAuth::Chatgpt(chatgpt_auth) => chatgpt_auth,
|
||
_ => return None,
|
||
};
|
||
|
||
let auth_dot_json = chatgpt_auth.current_auth_json()?;
|
||
if let Some(tokens) = auth_dot_json.tokens.as_ref()
|
||
&& let Ok(Some(expires_at)) = parse_jwt_expiration(&tokens.access_token)
|
||
{
|
||
return (expires_at
|
||
<= Utc::now()
|
||
+ chrono::Duration::minutes(CHATGPT_ACCESS_TOKEN_REFRESH_WINDOW_MINUTES))
|
||
.then_some(RefreshReason::ProactiveExpiry);
|
||
}
|
||
let last_refresh = auth_dot_json.last_refresh?;
|
||
(last_refresh < Utc::now() - chrono::Duration::days(TOKEN_REFRESH_INTERVAL))
|
||
.then_some(RefreshReason::Scheduled)
|
||
}
|
||
|
||
async fn refresh_external_auth(
|
||
&self,
|
||
reason: ExternalAuthRefreshReason,
|
||
) -> Result<(), RefreshTokenError> {
|
||
let Some(external_auth) = self.external_auth_provider() else {
|
||
return Err(RefreshTokenError::Transient(std::io::Error::other(
|
||
"external auth is not configured",
|
||
)));
|
||
};
|
||
let previous_account_id = self
|
||
.auth_cached()
|
||
.as_ref()
|
||
.and_then(CodexAuth::get_account_id);
|
||
let context = ExternalAuthRefreshContext {
|
||
reason,
|
||
previous_account_id,
|
||
};
|
||
|
||
let refreshed = external_auth
|
||
.refresh(context)
|
||
.await
|
||
.map_err(|error| external_auth.classify_error(error))?;
|
||
self.validate_external_auth(&refreshed, external_auth.as_ref())?;
|
||
self.commit_external_auth(refreshed)?;
|
||
Ok(())
|
||
}
|
||
|
||
fn commit_external_auth(&self, auth: CodexAuth) -> Result<(), RefreshTokenError> {
|
||
if auth.is_external_chatgpt_tokens() {
|
||
let auth_dot_json = auth.get_current_auth_json().ok_or_else(|| {
|
||
RefreshTokenError::Transient(std::io::Error::other(
|
||
"external ChatGPT auth tokens are missing auth state",
|
||
))
|
||
})?;
|
||
// Independent AuthManagers share external ChatGPT auth through the process-local store.
|
||
save_auth(
|
||
&self.codex_home,
|
||
&auth_dot_json,
|
||
AuthCredentialsStoreMode::Ephemeral,
|
||
AuthKeyringBackendKind::default(),
|
||
)
|
||
.map_err(RefreshTokenError::Transient)?;
|
||
}
|
||
|
||
self.set_cached_auth(Some(auth));
|
||
Ok(())
|
||
}
|
||
|
||
fn validate_external_auth(
|
||
&self,
|
||
auth: &CodexAuth,
|
||
external_auth: &dyn ExternalAuth,
|
||
) -> Result<(), RefreshTokenError> {
|
||
let allowed_login_methods = self.allowed_login_methods();
|
||
validate_auth_restrictions(
|
||
Some(&allowed_login_methods),
|
||
self.effective_chatgpt_workspaces().as_deref(),
|
||
auth,
|
||
)
|
||
.map_err(|error| external_auth.classify_error(std::io::Error::other(error)))
|
||
}
|
||
|
||
// Refreshes ChatGPT OAuth tokens, persists the updated auth state, and
|
||
// reloads the in-memory cache so callers immediately observe new tokens.
|
||
async fn refresh_and_persist_chatgpt_token(
|
||
&self,
|
||
auth: &ChatgptAuth,
|
||
refresh_token: String,
|
||
reason: RefreshReason,
|
||
) -> Result<(), RefreshTokenError> {
|
||
let refresh_response =
|
||
request_chatgpt_token_refresh(refresh_token, auth.client(), reason).await?;
|
||
|
||
persist_tokens(
|
||
auth.storage(),
|
||
refresh_response.id_token,
|
||
refresh_response.access_token,
|
||
refresh_response.refresh_token,
|
||
)
|
||
.map_err(RefreshTokenError::from)?;
|
||
self.reload().await;
|
||
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
fn auth_config_from(config: &impl AuthManagerConfig) -> AuthConfig {
|
||
AuthConfig {
|
||
codex_home: config.codex_home(),
|
||
auth_credentials_store_mode: config.cli_auth_credentials_store_mode(),
|
||
keyring_backend_kind: config.auth_keyring_backend_kind(),
|
||
forced_login_method: config.forced_login_method(),
|
||
chatgpt_base_url: Some(config.chatgpt_base_url()),
|
||
forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id(),
|
||
managed_auth_policy: config.managed_auth_policy(),
|
||
auth_route_config: config.auth_route_config(),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[path = "auth_tests.rs"]
|
||
mod tests;
|
||
|
||
#[cfg(test)]
|
||
#[path = "change_state_tests.rs"]
|
||
mod change_state_tests;
|
||
|
||
#[cfg(test)]
|
||
#[path = "account_user_id_tests.rs"]
|
||
mod account_user_id_tests;
|