mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
auth: generalize ExternalAuthTokens for bearer-only refreshers
This commit is contained in:
@@ -144,11 +144,11 @@ impl ExternalAuthRefresher for ExternalAuthRefreshBridge {
|
||||
let response: ChatgptAuthTokensRefreshResponse =
|
||||
serde_json::from_value(result).map_err(std::io::Error::other)?;
|
||||
|
||||
Ok(ExternalAuthTokens {
|
||||
access_token: response.access_token,
|
||||
chatgpt_account_id: response.chatgpt_account_id,
|
||||
chatgpt_plan_type: response.chatgpt_plan_type,
|
||||
})
|
||||
Ok(ExternalAuthTokens::chatgpt(
|
||||
response.access_token,
|
||||
response.chatgpt_account_id,
|
||||
response.chatgpt_plan_type,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,12 +102,12 @@ use crate::error::Result;
|
||||
use crate::flags::CODEX_RS_SSE_FIXTURE;
|
||||
use crate::model_provider_info::ModelProviderInfo;
|
||||
use crate::model_provider_info::WireApi;
|
||||
use crate::request_auth::RequestAuthContext;
|
||||
use crate::request_auth::RequestUnauthorizedRecovery;
|
||||
use crate::request_auth::ResolvedRequestAuth;
|
||||
use crate::request_auth::UnauthorizedRecoveryError;
|
||||
use crate::request_auth::UnauthorizedRecoveryExecution;
|
||||
use crate::request_auth::UnauthorizedRecoveryOutcome;
|
||||
use crate::request_auth::resolve_request_auth;
|
||||
use crate::response_debug_context::extract_response_debug_context;
|
||||
use crate::response_debug_context::extract_response_debug_context_from_api_error;
|
||||
use crate::response_debug_context::telemetry_api_error_message;
|
||||
@@ -135,7 +135,7 @@ pub(crate) const WEBSOCKET_CONNECT_TIMEOUT: Duration =
|
||||
/// configuration is per turn and is passed explicitly to streaming/unary methods.
|
||||
#[derive(Debug)]
|
||||
struct ModelClientState {
|
||||
auth_manager: Option<Arc<AuthManager>>,
|
||||
request_auth: RequestAuthContext,
|
||||
conversation_id: ThreadId,
|
||||
provider: ModelProviderInfo,
|
||||
auth_env_telemetry: AuthEnvTelemetry,
|
||||
@@ -261,7 +261,10 @@ impl ModelClient {
|
||||
let auth_env_telemetry = collect_auth_env_telemetry(&provider, codex_api_key_env_enabled);
|
||||
Self {
|
||||
state: Arc::new(ModelClientState {
|
||||
auth_manager,
|
||||
request_auth: RequestAuthContext::new(
|
||||
auth_manager,
|
||||
/*external_request_auth_source*/ None,
|
||||
),
|
||||
conversation_id,
|
||||
provider,
|
||||
auth_env_telemetry,
|
||||
@@ -326,6 +329,13 @@ impl ModelClient {
|
||||
activated
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_provider_bearer_token(&self) -> Result<Option<String>> {
|
||||
self.state
|
||||
.request_auth
|
||||
.resolve_external_request_auth_access_token()
|
||||
.await
|
||||
}
|
||||
|
||||
/// Compacts the current conversation history using the Compact endpoint.
|
||||
///
|
||||
/// This is a unary call (no streaming) that returns a new list of
|
||||
@@ -518,7 +528,7 @@ impl ModelClient {
|
||||
/// This centralizes setup used by both prewarm and normal request paths so they stay in
|
||||
/// lockstep when auth/provider resolution changes.
|
||||
async fn current_client_setup(&self) -> Result<ResolvedRequestAuth> {
|
||||
resolve_request_auth(self.state.auth_manager.as_ref(), &self.state.provider).await
|
||||
self.state.request_auth.resolve(&self.state.provider).await
|
||||
}
|
||||
|
||||
/// Opens a websocket connection using the same header and telemetry wiring as normal turns.
|
||||
@@ -997,13 +1007,15 @@ impl ModelClientSession {
|
||||
return Ok(stream);
|
||||
}
|
||||
|
||||
let mut unauthorized_recovery =
|
||||
RequestUnauthorizedRecovery::new(self.client.state.auth_manager.as_ref());
|
||||
let mut unauthorized_recovery = self.client.state.request_auth.unauthorized_recovery();
|
||||
let mut pending_retry = PendingUnauthorizedRetry::default();
|
||||
// Only loop after a successful auth-recovery step. Each retry must rebuild
|
||||
// the client and request headers before issuing the same streaming request again.
|
||||
loop {
|
||||
let client_setup = self.client.current_client_setup().await?;
|
||||
unauthorized_recovery.set_current_request_auth_access_token(
|
||||
client_setup.request_auth_access_token.clone(),
|
||||
);
|
||||
let transport = ReqwestTransport::new(build_reqwest_client());
|
||||
let request_auth_context = AuthRequestTelemetryContext::new(
|
||||
client_setup.auth.as_ref().map(CodexAuth::auth_mode),
|
||||
@@ -1085,13 +1097,15 @@ impl ModelClientSession {
|
||||
warmup: bool,
|
||||
request_trace: Option<W3cTraceContext>,
|
||||
) -> Result<WebsocketStreamOutcome> {
|
||||
let mut unauthorized_recovery =
|
||||
RequestUnauthorizedRecovery::new(self.client.state.auth_manager.as_ref());
|
||||
let mut unauthorized_recovery = self.client.state.request_auth.unauthorized_recovery();
|
||||
let mut pending_retry = PendingUnauthorizedRetry::default();
|
||||
// Only loop after a successful auth-recovery step. WebSocket auth is attached
|
||||
// during connect, so a recovered token requires a fresh connection attempt.
|
||||
loop {
|
||||
let client_setup = self.client.current_client_setup().await?;
|
||||
unauthorized_recovery.set_current_request_auth_access_token(
|
||||
client_setup.request_auth_access_token.clone(),
|
||||
);
|
||||
let request_auth_context = AuthRequestTelemetryContext::new(
|
||||
client_setup.auth.as_ref().map(CodexAuth::auth_mode),
|
||||
&client_setup.api_auth,
|
||||
@@ -1148,6 +1162,9 @@ impl ModelClientSession {
|
||||
session_telemetry,
|
||||
)
|
||||
.await?;
|
||||
if recovery.refreshes_request_auth {
|
||||
self.reset_websocket_session();
|
||||
}
|
||||
pending_retry = PendingUnauthorizedRetry::from_recovery(recovery);
|
||||
continue;
|
||||
}
|
||||
@@ -1628,6 +1645,29 @@ async fn handle_unauthorized(
|
||||
Err(CodexErr::Io(other))
|
||||
}
|
||||
},
|
||||
Err(UnauthorizedRecoveryError::RequestAuthSource { execution, error }) => {
|
||||
session_telemetry.record_auth_recovery(
|
||||
execution.mode,
|
||||
execution.phase,
|
||||
"recovery_failed_permanent",
|
||||
debug.request_id.as_deref(),
|
||||
debug.cf_ray.as_deref(),
|
||||
debug.auth_error.as_deref(),
|
||||
debug.auth_error_code.as_deref(),
|
||||
/*recovery_reason*/ None,
|
||||
/*auth_state_changed*/ None,
|
||||
);
|
||||
emit_feedback_auth_recovery_tags(
|
||||
execution.mode,
|
||||
execution.phase,
|
||||
"recovery_failed_permanent",
|
||||
debug.request_id.as_deref(),
|
||||
debug.cf_ray.as_deref(),
|
||||
debug.auth_error.as_deref(),
|
||||
debug.auth_error_code.as_deref(),
|
||||
);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ fn auth_request_telemetry_context_tracks_attached_auth_and_retry_phase() {
|
||||
mode: "managed",
|
||||
phase: "refresh_token",
|
||||
auth_state_changed: None,
|
||||
refreshes_request_auth: false,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -12,9 +12,8 @@ use crate::model_provider_info::ModelProviderInfo;
|
||||
use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig;
|
||||
use crate::models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets;
|
||||
use crate::models_manager::model_info;
|
||||
use crate::request_auth::RequestUnauthorizedRecovery;
|
||||
use crate::request_auth::RequestAuthContext;
|
||||
use crate::request_auth::UnauthorizedRecoveryOutcome;
|
||||
use crate::request_auth::resolve_request_auth;
|
||||
use crate::response_debug_context::extract_response_debug_context;
|
||||
use crate::response_debug_context::telemetry_transport_error_message;
|
||||
use crate::util::FeedbackRequestTags;
|
||||
@@ -179,7 +178,7 @@ pub struct ModelsManager {
|
||||
remote_models: RwLock<Vec<ModelInfo>>,
|
||||
catalog_mode: CatalogMode,
|
||||
collaboration_modes_config: CollaborationModesConfig,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
request_auth: RequestAuthContext,
|
||||
etag: RwLock<Option<String>>,
|
||||
cache_manager: ModelsCacheManager,
|
||||
provider: ModelProviderInfo,
|
||||
@@ -231,7 +230,10 @@ impl ModelsManager {
|
||||
remote_models: RwLock::new(remote_models),
|
||||
catalog_mode,
|
||||
collaboration_modes_config,
|
||||
auth_manager,
|
||||
request_auth: RequestAuthContext::new(
|
||||
Some(auth_manager.clone()),
|
||||
/*external_request_auth_source*/ None,
|
||||
),
|
||||
etag: RwLock::new(None),
|
||||
cache_manager,
|
||||
provider,
|
||||
@@ -398,7 +400,13 @@ impl ModelsManager {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if self.auth_manager.auth_mode() != Some(AuthMode::Chatgpt) {
|
||||
if self
|
||||
.request_auth
|
||||
.auth_manager()
|
||||
.and_then(|auth_manager| auth_manager.auth_mode())
|
||||
!= Some(AuthMode::Chatgpt)
|
||||
&& !self.provider.has_command_auth()
|
||||
{
|
||||
if matches!(
|
||||
refresh_strategy,
|
||||
RefreshStrategy::Offline | RefreshStrategy::OnlineIfUncached
|
||||
@@ -435,16 +443,20 @@ impl ModelsManager {
|
||||
codex_otel::start_global_timer("codex.remote_models.fetch_update.duration_ms", &[]);
|
||||
let auth_env = collect_auth_env_telemetry(
|
||||
&self.provider,
|
||||
self.auth_manager.codex_api_key_env_enabled(),
|
||||
self.request_auth
|
||||
.auth_manager()
|
||||
.is_some_and(|manager| manager.codex_api_key_env_enabled()),
|
||||
);
|
||||
let client_version = crate::models_manager::client_version_to_whole();
|
||||
let mut unauthorized_recovery = RequestUnauthorizedRecovery::new(Some(&self.auth_manager));
|
||||
let mut unauthorized_recovery = self.request_auth.unauthorized_recovery();
|
||||
|
||||
// Only loop after a successful auth-recovery step so `/models` retries with
|
||||
// the same freshly resolved auth state as normal request paths.
|
||||
loop {
|
||||
let request_auth =
|
||||
resolve_request_auth(Some(&self.auth_manager), &self.provider).await?;
|
||||
let request_auth = self.request_auth.resolve(&self.provider).await?;
|
||||
unauthorized_recovery.set_current_request_auth_access_token(
|
||||
request_auth.request_auth_access_token.clone(),
|
||||
);
|
||||
let transport = ReqwestTransport::new(build_reqwest_client());
|
||||
let request_telemetry: Arc<dyn RequestTelemetry> = Arc::new(ModelsRequestTelemetry {
|
||||
auth_mode: request_auth
|
||||
@@ -544,7 +556,12 @@ impl ModelsManager {
|
||||
remote_models.sort_by(|a, b| a.priority.cmp(&b.priority));
|
||||
|
||||
let mut presets: Vec<ModelPreset> = remote_models.into_iter().map(Into::into).collect();
|
||||
let chatgpt_mode = matches!(self.auth_manager.auth_mode(), Some(AuthMode::Chatgpt));
|
||||
let chatgpt_mode = matches!(
|
||||
self.request_auth
|
||||
.auth_manager()
|
||||
.and_then(|auth_manager| auth_manager.auth_mode()),
|
||||
Some(AuthMode::Chatgpt)
|
||||
);
|
||||
presets = ModelPreset::filter_by_auth(presets, chatgpt_mode);
|
||||
|
||||
ModelPreset::mark_default_by_picker_visibility(&mut presets);
|
||||
|
||||
@@ -1,41 +1,105 @@
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::api_bridge::CoreAuthProvider;
|
||||
use crate::api_bridge::auth_provider_from_auth;
|
||||
use crate::api_bridge::auth_provider_from_resolved_provider_token;
|
||||
use crate::auth::AuthManager;
|
||||
use crate::auth::AuthMode;
|
||||
use crate::auth::CodexAuth;
|
||||
use crate::auth::ExternalAuthRefreshContext;
|
||||
use crate::auth::ExternalAuthRefreshReason;
|
||||
use crate::auth::ExternalAuthRefresher;
|
||||
use crate::auth::RefreshTokenError;
|
||||
use crate::auth::UnauthorizedRecovery;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result;
|
||||
use crate::model_provider_info::ModelProviderInfo;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct RequestAuthContext {
|
||||
auth_manager: Option<Arc<AuthManager>>,
|
||||
external_request_auth_source: Option<Arc<dyn ExternalAuthRefresher>>,
|
||||
}
|
||||
|
||||
impl RequestAuthContext {
|
||||
pub(crate) fn new(
|
||||
auth_manager: Option<Arc<AuthManager>>,
|
||||
external_request_auth_source: Option<Arc<dyn ExternalAuthRefresher>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
auth_manager,
|
||||
external_request_auth_source,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve(
|
||||
&self,
|
||||
provider: &ModelProviderInfo,
|
||||
) -> Result<ResolvedRequestAuth> {
|
||||
let auth = match self.auth_manager.as_ref() {
|
||||
Some(manager) => manager.auth().await,
|
||||
None => None,
|
||||
};
|
||||
let auth_mode = auth.as_ref().map(CodexAuth::auth_mode);
|
||||
let request_auth_access_token = self.resolve_external_request_auth_access_token().await?;
|
||||
let api_provider = provider.to_api_provider(auth_mode)?;
|
||||
let api_auth = auth_provider_from_resolved_provider_token(
|
||||
auth.clone(),
|
||||
provider,
|
||||
request_auth_access_token.clone(),
|
||||
)?;
|
||||
Ok(ResolvedRequestAuth {
|
||||
auth,
|
||||
auth_mode,
|
||||
api_provider,
|
||||
api_auth,
|
||||
request_auth_access_token,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn unauthorized_recovery(&self) -> RequestUnauthorizedRecovery {
|
||||
RequestUnauthorizedRecovery::new(self)
|
||||
}
|
||||
|
||||
pub(crate) fn auth_manager(&self) -> Option<&Arc<AuthManager>> {
|
||||
self.auth_manager.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_external_request_auth_access_token(
|
||||
&self,
|
||||
) -> Result<Option<String>> {
|
||||
let Some(source) = self.external_request_auth_source.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let tokens = source
|
||||
.resolve()
|
||||
.await
|
||||
.map_err(map_external_request_auth_error)?;
|
||||
Ok(tokens.map(|tokens| tokens.access_token))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for RequestAuthContext {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("RequestAuthContext")
|
||||
.field("has_auth_manager", &self.auth_manager.is_some())
|
||||
.field(
|
||||
"has_external_request_auth_source",
|
||||
&self.external_request_auth_source.is_some(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ResolvedRequestAuth {
|
||||
pub(crate) auth: Option<CodexAuth>,
|
||||
pub(crate) auth_mode: Option<AuthMode>,
|
||||
pub(crate) api_provider: codex_api::Provider,
|
||||
pub(crate) api_auth: CoreAuthProvider,
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_request_auth(
|
||||
auth_manager: Option<&Arc<AuthManager>>,
|
||||
provider: &ModelProviderInfo,
|
||||
) -> Result<ResolvedRequestAuth> {
|
||||
let auth = match auth_manager {
|
||||
Some(manager) => manager.auth().await,
|
||||
None => None,
|
||||
};
|
||||
let auth_mode = auth.as_ref().map(CodexAuth::auth_mode);
|
||||
let api_provider = provider.to_api_provider(auth_mode)?;
|
||||
let api_auth = auth_provider_from_auth(auth.clone(), provider)?;
|
||||
Ok(ResolvedRequestAuth {
|
||||
auth,
|
||||
auth_mode,
|
||||
api_provider,
|
||||
api_auth,
|
||||
})
|
||||
pub(crate) request_auth_access_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -43,6 +107,7 @@ pub(crate) struct UnauthorizedRecoveryExecution {
|
||||
pub(crate) mode: &'static str,
|
||||
pub(crate) phase: &'static str,
|
||||
pub(crate) auth_state_changed: Option<bool>,
|
||||
pub(crate) refreshes_request_auth: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -64,6 +129,10 @@ pub(crate) enum UnauthorizedRecoveryError {
|
||||
execution: UnauthorizedRecoveryExecution,
|
||||
error: RefreshTokenError,
|
||||
},
|
||||
RequestAuthSource {
|
||||
execution: UnauthorizedRecoveryExecution,
|
||||
error: CodexErr,
|
||||
},
|
||||
}
|
||||
|
||||
impl UnauthorizedRecoveryError {
|
||||
@@ -73,6 +142,7 @@ impl UnauthorizedRecoveryError {
|
||||
RefreshTokenError::Permanent(failed) => CodexErr::RefreshTokenFailed(failed),
|
||||
RefreshTokenError::Transient(error) => CodexErr::Io(error),
|
||||
},
|
||||
Self::RequestAuthSource { error, .. } => error,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,25 +152,75 @@ impl UnauthorizedRecoveryError {
|
||||
/// The request loops reuse the same instance across retries so this type can enforce the
|
||||
/// recovery ordering and make sure each one-shot step only runs once.
|
||||
pub(crate) struct RequestUnauthorizedRecovery {
|
||||
external_request_auth_source: Option<Arc<dyn ExternalAuthRefresher>>,
|
||||
external_request_auth_retry_remaining: bool,
|
||||
current_request_auth_access_token: Option<String>,
|
||||
auth_recovery: Option<UnauthorizedRecovery>,
|
||||
}
|
||||
|
||||
impl RequestUnauthorizedRecovery {
|
||||
pub(crate) fn new(auth_manager: Option<&Arc<AuthManager>>) -> Self {
|
||||
pub(crate) fn new(request_auth: &RequestAuthContext) -> Self {
|
||||
Self {
|
||||
auth_recovery: auth_manager.map(AuthManager::unauthorized_recovery),
|
||||
external_request_auth_source: request_auth.external_request_auth_source.clone(),
|
||||
external_request_auth_retry_remaining: request_auth
|
||||
.external_request_auth_source
|
||||
.is_some(),
|
||||
current_request_auth_access_token: None,
|
||||
auth_recovery: request_auth
|
||||
.auth_manager
|
||||
.as_ref()
|
||||
.map(AuthManager::unauthorized_recovery),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_current_request_auth_access_token(&mut self, access_token: Option<String>) {
|
||||
self.current_request_auth_access_token = access_token;
|
||||
}
|
||||
|
||||
/// Runs the next available auth-recovery step, if any.
|
||||
///
|
||||
/// Provider auth refresh is attempted first because it is specific to the configured
|
||||
/// model provider and should repair the currently attached bearer token before falling
|
||||
/// back to ChatGPT-managed recovery. Once all recovery steps have been consumed, this
|
||||
/// returns `UnauthorizedRecoveryOutcome::Unavailable` so the caller can stop retrying.
|
||||
/// Request-auth refresh is attempted first because it repairs the bearer token attached to the
|
||||
/// failing request directly. If no request-auth source is configured for the request, or the
|
||||
/// retry has already been consumed, recovery falls back to the ChatGPT auth-manager flow.
|
||||
/// Once all recovery steps have been consumed, this returns
|
||||
/// `UnauthorizedRecoveryOutcome::Unavailable` so the caller can stop retrying.
|
||||
pub(crate) async fn next(
|
||||
&mut self,
|
||||
) -> std::result::Result<UnauthorizedRecoveryOutcome, UnauthorizedRecoveryError> {
|
||||
if self.external_request_auth_retry_remaining
|
||||
&& let Some(source) = self.external_request_auth_source.as_ref()
|
||||
&& self.current_request_auth_access_token.is_some()
|
||||
{
|
||||
self.external_request_auth_retry_remaining = false;
|
||||
let execution = UnauthorizedRecoveryExecution {
|
||||
mode: "external_request_auth",
|
||||
phase: "refresh",
|
||||
auth_state_changed: None,
|
||||
refreshes_request_auth: true,
|
||||
};
|
||||
return match source
|
||||
.refresh_after_unauthorized(
|
||||
ExternalAuthRefreshContext {
|
||||
reason: ExternalAuthRefreshReason::Unauthorized,
|
||||
previous_account_id: None,
|
||||
},
|
||||
self.current_request_auth_access_token.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(auth_state_changed) => Ok(UnauthorizedRecoveryOutcome::Recovered(
|
||||
UnauthorizedRecoveryExecution {
|
||||
auth_state_changed,
|
||||
..execution
|
||||
},
|
||||
)),
|
||||
Err(error) => Err(UnauthorizedRecoveryError::RequestAuthSource {
|
||||
execution,
|
||||
error: map_external_request_auth_error(error),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(recovery) = self.auth_recovery.as_mut()
|
||||
&& recovery.has_next()
|
||||
{
|
||||
@@ -108,6 +228,7 @@ impl RequestUnauthorizedRecovery {
|
||||
mode: recovery.mode_name(),
|
||||
phase: recovery.step_name(),
|
||||
auth_state_changed: None,
|
||||
refreshes_request_auth: false,
|
||||
};
|
||||
return match recovery.next().await {
|
||||
Ok(step_result) => Ok(UnauthorizedRecoveryOutcome::Recovered(
|
||||
@@ -120,6 +241,31 @@ impl RequestUnauthorizedRecovery {
|
||||
};
|
||||
}
|
||||
|
||||
if self.external_request_auth_source.is_some()
|
||||
&& self.current_request_auth_access_token.is_none()
|
||||
{
|
||||
return Ok(UnauthorizedRecoveryOutcome::Unavailable(
|
||||
UnauthorizedRecoveryUnavailable {
|
||||
mode: "external_request_auth",
|
||||
phase: "refresh",
|
||||
reason: "request_auth_not_attached",
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
if self.external_request_auth_source.is_some()
|
||||
&& !self.external_request_auth_retry_remaining
|
||||
&& self.auth_recovery.is_none()
|
||||
{
|
||||
return Ok(UnauthorizedRecoveryOutcome::Unavailable(
|
||||
UnauthorizedRecoveryUnavailable {
|
||||
mode: "external_request_auth",
|
||||
phase: "refresh",
|
||||
reason: "request_auth_recovery_exhausted",
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
let unavailable = match self.auth_recovery.as_ref() {
|
||||
Some(recovery) => UnauthorizedRecoveryUnavailable {
|
||||
mode: recovery.mode_name(),
|
||||
@@ -135,3 +281,7 @@ impl RequestUnauthorizedRecovery {
|
||||
Ok(UnauthorizedRecoveryOutcome::Unavailable(unavailable))
|
||||
}
|
||||
}
|
||||
|
||||
fn map_external_request_auth_error(error: io::Error) -> CodexErr {
|
||||
CodexErr::InvalidRequest(error.to_string())
|
||||
}
|
||||
|
||||
@@ -205,6 +205,16 @@ fn unauthorized_recovery_reports_mode_and_step_names() {
|
||||
assert_eq!(external.step_name(), "external_refresh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_auth_tokens_without_chatgpt_metadata_cannot_seed_chatgpt_auth() {
|
||||
let err = AuthDotJson::from_external_tokens(&ExternalAuthTokens::access_token_only("token"))
|
||||
.expect_err("ChatGPT auth should require ChatGPT metadata");
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"external auth tokens are missing ChatGPT metadata"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() {
|
||||
let codex_home = tempdir().unwrap();
|
||||
|
||||
@@ -90,11 +90,43 @@ pub enum RefreshTokenError {
|
||||
Transient(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ExternalAuthChatgptMetadata {
|
||||
pub account_id: String,
|
||||
pub plan_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ExternalAuthTokens {
|
||||
pub access_token: String,
|
||||
pub chatgpt_account_id: String,
|
||||
pub chatgpt_plan_type: Option<String>,
|
||||
pub chatgpt_metadata: Option<ExternalAuthChatgptMetadata>,
|
||||
}
|
||||
|
||||
impl ExternalAuthTokens {
|
||||
pub fn access_token_only(access_token: impl Into<String>) -> Self {
|
||||
Self {
|
||||
access_token: access_token.into(),
|
||||
chatgpt_metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chatgpt(
|
||||
access_token: impl Into<String>,
|
||||
chatgpt_account_id: impl Into<String>,
|
||||
chatgpt_plan_type: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
access_token: access_token.into(),
|
||||
chatgpt_metadata: Some(ExternalAuthChatgptMetadata {
|
||||
account_id: chatgpt_account_id.into(),
|
||||
plan_type: chatgpt_plan_type,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chatgpt_metadata(&self) -> Option<&ExternalAuthChatgptMetadata> {
|
||||
self.chatgpt_metadata.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -110,10 +142,23 @@ pub struct ExternalAuthRefreshContext {
|
||||
|
||||
#[async_trait]
|
||||
pub trait ExternalAuthRefresher: Send + Sync {
|
||||
async fn resolve(&self) -> std::io::Result<Option<ExternalAuthTokens>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn refresh(
|
||||
&self,
|
||||
context: ExternalAuthRefreshContext,
|
||||
) -> std::io::Result<ExternalAuthTokens>;
|
||||
|
||||
async fn refresh_after_unauthorized(
|
||||
&self,
|
||||
context: ExternalAuthRefreshContext,
|
||||
previous_access_token: Option<&str>,
|
||||
) -> std::io::Result<Option<bool>> {
|
||||
let refreshed = self.refresh(context).await?;
|
||||
Ok(previous_access_token.map(|token| token != refreshed.access_token))
|
||||
}
|
||||
}
|
||||
|
||||
impl RefreshTokenError {
|
||||
@@ -736,11 +781,16 @@ fn refresh_token_endpoint() -> String {
|
||||
|
||||
impl AuthDotJson {
|
||||
fn from_external_tokens(external: &ExternalAuthTokens) -> std::io::Result<Self> {
|
||||
let Some(chatgpt_metadata) = external.chatgpt_metadata() else {
|
||||
return Err(std::io::Error::other(
|
||||
"external auth tokens are missing ChatGPT metadata",
|
||||
));
|
||||
};
|
||||
let mut token_info =
|
||||
parse_chatgpt_jwt_claims(&external.access_token).map_err(std::io::Error::other)?;
|
||||
token_info.chatgpt_account_id = Some(external.chatgpt_account_id.clone());
|
||||
token_info.chatgpt_plan_type = external
|
||||
.chatgpt_plan_type
|
||||
token_info.chatgpt_account_id = Some(chatgpt_metadata.account_id.clone());
|
||||
token_info.chatgpt_plan_type = chatgpt_metadata
|
||||
.plan_type
|
||||
.as_deref()
|
||||
.map(InternalPlanType::from_raw_value)
|
||||
.or(token_info.chatgpt_plan_type)
|
||||
@@ -749,7 +799,7 @@ impl AuthDotJson {
|
||||
id_token: token_info,
|
||||
access_token: external.access_token.clone(),
|
||||
refresh_token: String::new(),
|
||||
account_id: Some(external.chatgpt_account_id.clone()),
|
||||
account_id: Some(chatgpt_metadata.account_id.clone()),
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
@@ -765,11 +815,11 @@ impl AuthDotJson {
|
||||
chatgpt_account_id: &str,
|
||||
chatgpt_plan_type: Option<&str>,
|
||||
) -> std::io::Result<Self> {
|
||||
let external = ExternalAuthTokens {
|
||||
access_token: access_token.to_string(),
|
||||
chatgpt_account_id: chatgpt_account_id.to_string(),
|
||||
chatgpt_plan_type: chatgpt_plan_type.map(str::to_string),
|
||||
};
|
||||
let external = ExternalAuthTokens::chatgpt(
|
||||
access_token,
|
||||
chatgpt_account_id,
|
||||
chatgpt_plan_type.map(str::to_string),
|
||||
);
|
||||
Self::from_external_tokens(&external)
|
||||
}
|
||||
|
||||
@@ -1457,13 +1507,18 @@ impl AuthManager {
|
||||
};
|
||||
|
||||
let refreshed = refresher.refresh(context).await?;
|
||||
let Some(chatgpt_metadata) = refreshed.chatgpt_metadata() else {
|
||||
return Err(RefreshTokenError::Transient(std::io::Error::other(
|
||||
"external auth refresh did not return ChatGPT metadata",
|
||||
)));
|
||||
};
|
||||
if let Some(expected_workspace_id) = forced_chatgpt_workspace_id.as_deref()
|
||||
&& refreshed.chatgpt_account_id != expected_workspace_id
|
||||
&& chatgpt_metadata.account_id != expected_workspace_id
|
||||
{
|
||||
return Err(RefreshTokenError::Transient(std::io::Error::other(
|
||||
format!(
|
||||
"external auth refresh returned workspace {:?}, expected {expected_workspace_id:?}",
|
||||
refreshed.chatgpt_account_id,
|
||||
chatgpt_metadata.account_id,
|
||||
),
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -22,6 +22,11 @@ pub use auth::AuthManager;
|
||||
pub use auth::CLIENT_ID;
|
||||
pub use auth::CODEX_API_KEY_ENV_VAR;
|
||||
pub use auth::CodexAuth;
|
||||
pub use auth::ExternalAuthChatgptMetadata;
|
||||
pub use auth::ExternalAuthRefreshContext;
|
||||
pub use auth::ExternalAuthRefreshReason;
|
||||
pub use auth::ExternalAuthRefresher;
|
||||
pub use auth::ExternalAuthTokens;
|
||||
pub use auth::OPENAI_API_KEY_ENV_VAR;
|
||||
pub use auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
|
||||
pub use auth::RefreshTokenError;
|
||||
|
||||
Reference in New Issue
Block a user