auth: let AuthManager own external bearer auth (#16287)

## Summary

`AuthManager` and `UnauthorizedRecovery` already own token resolution
and staged `401` recovery. The missing piece for provider auth was a
bearer-only mode that still fit that design, instead of pushing a second
auth abstraction into `codex-core`.

This PR keeps the design centered on `AuthManager`: it teaches
`codex-login` how to own external bearer auth directly so later provider
work can keep calling `AuthManager.auth()` and `UnauthorizedRecovery`.

## Motivation

This is the middle layer for #15189.

The intended design is still:

- `AuthManager` encapsulates token storage and refresh
- `UnauthorizedRecovery` powers staged `401` recovery
- all request tokens go through `AuthManager.auth()`

This PR makes that possible for provider-backed bearer tokens by adding
a bearer-only auth mode inside `AuthManager` instead of building
parallel request-auth plumbing in `core`.

## What Changed

- move `ModelProviderAuthInfo` into `codex-protocol` so `core` and
`login` share one config shape
- add `login/src/auth/external_bearer.rs`, which runs the configured
command, caches the bearer token in memory, and refreshes it after `401`
- add `AuthManager::external_bearer_only(...)` for provider-scoped
request paths that should use command-backed bearer auth without
mutating the shared OpenAI auth manager
- add `AuthManager::shared_with_external_chatgpt_auth_refresher(...)`
and rename the other `AuthManager` helpers that only apply to external
ChatGPT auth so the ChatGPT-only path is explicit at the call site
- keep external ChatGPT refresh behavior unchanged while ensuring
bearer-only external auth never persists to `auth.json`

## Testing

- `cargo test -p codex-login`
- `cargo test -p codex-protocol`





---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/16287).
* #16288
* __->__ #16287
This commit is contained in:
Michael Bolin
2026-03-31 01:26:17 -07:00
committed by GitHub
Unverified
parent ea650a91b3
commit 0071968829
7 changed files with 544 additions and 45 deletions
@@ -1015,7 +1015,7 @@ impl CodexMessageProcessor {
&mut self,
params: &LoginApiKeyParams,
) -> std::result::Result<(), JSONRPCErrorError> {
if self.auth_manager.is_external_auth_active() {
if self.auth_manager.is_external_chatgpt_auth_active() {
return Err(self.external_auth_active_error());
}
@@ -1094,7 +1094,7 @@ impl CodexMessageProcessor {
) -> std::result::Result<LoginServerOptions, JSONRPCErrorError> {
let config = self.config.as_ref();
if self.auth_manager.is_external_auth_active() {
if self.auth_manager.is_external_chatgpt_auth_active() {
return Err(self.external_auth_active_error());
}
@@ -1531,7 +1531,7 @@ impl CodexMessageProcessor {
}
async fn refresh_token_if_requested(&self, do_refresh: bool) -> RefreshTokenRequestOutcome {
if self.auth_manager.is_external_auth_active() {
if self.auth_manager.is_external_chatgpt_auth_active() {
return RefreshTokenRequestOutcome::NotAttemptedOrSucceeded;
}
if do_refresh && let Err(err) = self.auth_manager.refresh_token().await {
+5 -5
View File
@@ -206,10 +206,13 @@ impl MessageProcessor {
session_source,
enable_codex_api_key_env,
} = args;
let auth_manager = AuthManager::shared(
let auth_manager = AuthManager::shared_with_external_chatgpt_auth_refresher(
config.codex_home.clone(),
enable_codex_api_key_env,
config.cli_auth_credentials_store_mode,
Arc::new(ExternalAuthRefreshBridge {
outgoing: outgoing.clone(),
}),
);
let thread_manager = Arc::new(ThreadManager::new(
config.as_ref(),
@@ -223,9 +226,6 @@ impl MessageProcessor {
environment_manager,
));
auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone());
auth_manager.set_external_auth_refresher(Arc::new(ExternalAuthRefreshBridge {
outgoing: outgoing.clone(),
}));
let analytics_events_client = AnalyticsEventsClient::new(
Arc::clone(&auth_manager),
config.chatgpt_base_url.trim_end_matches('/').to_string(),
@@ -282,7 +282,7 @@ impl MessageProcessor {
}
pub(crate) fn clear_runtime_references(&self) {
self.auth_manager.clear_external_auth_refresher();
self.auth_manager.clear_external_chatgpt_auth_refresher();
}
pub(crate) async fn process_request(
+176
View File
@@ -8,10 +8,12 @@ use codex_protocol::account::PlanType as AccountPlanType;
use base64::Engine;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::ModelProviderAuthInfo;
use pretty_assertions::assert_eq;
use serde::Serialize;
use serde_json::json;
use std::sync::Arc;
use tempfile::TempDir;
use tempfile::tempdir;
#[tokio::test]
@@ -265,6 +267,180 @@ fn external_auth_tokens_without_chatgpt_metadata_cannot_seed_chatgpt_auth() {
);
}
#[tokio::test]
async fn external_bearer_only_auth_manager_uses_cached_provider_token() {
let script = ProviderAuthScript::new(&["provider-token", "next-token"]).unwrap();
let manager = AuthManager::external_bearer_only(script.auth_config());
let first = manager
.auth()
.await
.and_then(|auth| auth.api_key().map(str::to_string));
let second = manager
.auth()
.await
.and_then(|auth| auth.api_key().map(str::to_string));
assert_eq!(first.as_deref(), Some("provider-token"));
assert_eq!(second.as_deref(), Some("provider-token"));
}
#[tokio::test]
async fn external_bearer_only_auth_manager_returns_none_when_command_fails() {
let script = ProviderAuthScript::new_failing().unwrap();
let manager = AuthManager::external_bearer_only(script.auth_config());
assert_eq!(manager.auth().await, None);
}
#[tokio::test]
async fn unauthorized_recovery_uses_external_refresh_for_bearer_manager() {
let script = ProviderAuthScript::new(&["provider-token", "refreshed-provider-token"]).unwrap();
let manager = AuthManager::external_bearer_only(script.auth_config());
let initial_token = manager
.auth()
.await
.and_then(|auth| auth.api_key().map(str::to_string));
let mut recovery = manager.unauthorized_recovery();
assert!(recovery.has_next());
assert_eq!(recovery.mode_name(), "external");
assert_eq!(recovery.step_name(), "external_refresh");
let result = recovery
.next()
.await
.expect("external refresh should succeed");
assert_eq!(result.auth_state_changed(), Some(true));
let refreshed_token = manager
.auth()
.await
.and_then(|auth| auth.api_key().map(str::to_string));
assert_eq!(initial_token.as_deref(), Some("provider-token"));
assert_eq!(refreshed_token.as_deref(), Some("refreshed-provider-token"));
}
struct ProviderAuthScript {
tempdir: TempDir,
command: String,
args: Vec<String>,
}
impl ProviderAuthScript {
fn new(tokens: &[&str]) -> std::io::Result<Self> {
let tempdir = tempfile::tempdir()?;
let token_file = tempdir.path().join("tokens.txt");
let mut token_file_contents = String::new();
for token in tokens {
token_file_contents.push_str(token);
token_file_contents.push('\n');
}
std::fs::write(&token_file, token_file_contents)?;
#[cfg(unix)]
let (command, args) = {
let script_path = tempdir.path().join("print-token.sh");
std::fs::write(
&script_path,
r#"#!/bin/sh
first_line=$(sed -n '1p' tokens.txt)
printf '%s\n' "$first_line"
tail -n +2 tokens.txt > tokens.next
mv tokens.next tokens.txt
"#,
)?;
let mut permissions = std::fs::metadata(&script_path)?.permissions();
{
use std::os::unix::fs::PermissionsExt;
permissions.set_mode(0o755);
}
std::fs::set_permissions(&script_path, permissions)?;
("./print-token.sh".to_string(), Vec::new())
};
#[cfg(windows)]
let (command, args) = {
let script_path = tempdir.path().join("print-token.ps1");
std::fs::write(
&script_path,
r#"$lines = Get-Content -Path tokens.txt
if ($lines.Count -eq 0) { exit 1 }
Write-Output $lines[0]
$lines | Select-Object -Skip 1 | Set-Content -Path tokens.txt
"#,
)?;
(
"powershell".to_string(),
vec![
"-NoProfile".to_string(),
"-ExecutionPolicy".to_string(),
"Bypass".to_string(),
"-File".to_string(),
".\\print-token.ps1".to_string(),
],
)
};
Ok(Self {
tempdir,
command,
args,
})
}
fn new_failing() -> std::io::Result<Self> {
let tempdir = tempfile::tempdir()?;
#[cfg(unix)]
let (command, args) = {
let script_path = tempdir.path().join("fail.sh");
std::fs::write(
&script_path,
r#"#!/bin/sh
exit 1
"#,
)?;
let mut permissions = std::fs::metadata(&script_path)?.permissions();
{
use std::os::unix::fs::PermissionsExt;
permissions.set_mode(0o755);
}
std::fs::set_permissions(&script_path, permissions)?;
("./fail.sh".to_string(), Vec::new())
};
#[cfg(windows)]
let (command, args) = (
"powershell".to_string(),
vec![
"-NoProfile".to_string(),
"-ExecutionPolicy".to_string(),
"Bypass".to_string(),
"-Command".to_string(),
"exit 1".to_string(),
],
);
Ok(Self {
tempdir,
command,
args,
})
}
fn auth_config(&self) -> ModelProviderAuthInfo {
serde_json::from_value(json!({
"command": self.command,
"args": self.args,
"timeout_ms": 1000,
"refresh_interval_ms": 60000,
"cwd": self.tempdir.path(),
}))
.expect("provider auth config should deserialize")
}
}
struct AuthFileParams {
openai_api_key: Option<String>,
chatgpt_plan_type: Option<String>,
+145
View File
@@ -0,0 +1,145 @@
use codex_protocol::config_types::ModelProviderAuthInfo;
use std::fmt;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Instant;
use tokio::process::Command;
use tokio::sync::Mutex;
#[derive(Clone)]
pub(crate) struct ExternalBearerAuth {
state: Arc<ExternalBearerAuthState>,
}
impl ExternalBearerAuth {
pub(crate) fn new(config: ModelProviderAuthInfo) -> Self {
Self {
state: Arc::new(ExternalBearerAuthState::new(config)),
}
}
pub(crate) async fn resolve_access_token(&self) -> io::Result<String> {
let mut cached = self.state.cached_token.lock().await;
if let Some(cached_token) = cached.as_ref()
&& cached_token.fetched_at.elapsed() < self.state.config.refresh_interval()
{
return Ok(cached_token.access_token.clone());
}
let access_token = run_provider_auth_command(&self.state.config).await?;
*cached = Some(CachedExternalBearerToken {
access_token: access_token.clone(),
fetched_at: Instant::now(),
});
Ok(access_token)
}
pub(crate) async fn refresh_after_unauthorized(&self) -> io::Result<()> {
let access_token = run_provider_auth_command(&self.state.config).await?;
let mut cached = self.state.cached_token.lock().await;
*cached = Some(CachedExternalBearerToken {
access_token,
fetched_at: Instant::now(),
});
Ok(())
}
}
impl fmt::Debug for ExternalBearerAuth {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ExternalBearerAuth").finish_non_exhaustive()
}
}
struct ExternalBearerAuthState {
config: ModelProviderAuthInfo,
cached_token: Mutex<Option<CachedExternalBearerToken>>,
}
impl ExternalBearerAuthState {
fn new(config: ModelProviderAuthInfo) -> Self {
Self {
config,
cached_token: Mutex::new(None),
}
}
}
struct CachedExternalBearerToken {
access_token: String,
fetched_at: Instant,
}
async fn run_provider_auth_command(config: &ModelProviderAuthInfo) -> io::Result<String> {
let program = resolve_provider_auth_program(&config.command, &config.cwd)?;
let mut command = Command::new(&program);
command
.args(&config.args)
.current_dir(config.cwd.as_path())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let output = tokio::time::timeout(config.timeout(), command.output())
.await
.map_err(|_| {
io::Error::other(format!(
"provider auth command `{}` timed out after {} ms",
config.command,
config.timeout_ms.get()
))
})?
.map_err(|err| {
io::Error::other(format!(
"provider auth command `{}` failed to start: {err}",
config.command
))
})?;
if !output.status.success() {
let status = output.status;
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stderr_suffix = if stderr.is_empty() {
String::new()
} else {
format!(": {stderr}")
};
return Err(io::Error::other(format!(
"provider auth command `{}` exited with status {status}{stderr_suffix}",
config.command
)));
}
let stdout = String::from_utf8(output.stdout).map_err(|_| {
io::Error::other(format!(
"provider auth command `{}` wrote non-UTF-8 data to stdout",
config.command
))
})?;
let access_token = stdout.trim().to_string();
if access_token.is_empty() {
return Err(io::Error::other(format!(
"provider auth command `{}` produced an empty token",
config.command
)));
}
Ok(access_token)
}
fn resolve_provider_auth_program(command: &str, cwd: &Path) -> io::Result<PathBuf> {
let path = Path::new(command);
if path.is_absolute() {
return Ok(path.to_path_buf());
}
if path.components().count() > 1 {
return Ok(cwd.join(path));
}
Ok(PathBuf::from(command))
}
+136 -37
View File
@@ -16,7 +16,9 @@ use tokio::sync::Mutex as AsyncMutex;
use codex_app_server_protocol::AuthMode as ApiAuthMode;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::ModelProviderAuthInfo;
use super::external_bearer::ExternalBearerAuth;
use crate::auth::error::RefreshTokenFailedError;
use crate::auth::error::RefreshTokenFailedReason;
pub use crate::auth::storage::AuthCredentialsStoreMode;
@@ -840,8 +842,6 @@ impl AuthDotJson {
#[derive(Clone)]
struct CachedAuth {
auth: Option<CodexAuth>,
/// Callback used to refresh external auth by asking the parent app for new tokens.
external_refresher: Option<Arc<dyn ExternalAuthRefresher>>,
/// 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>,
@@ -853,6 +853,27 @@ struct AuthScopedRefreshFailure {
error: RefreshTokenFailedError,
}
#[derive(Clone)]
enum ExternalAuth {
Bearer(ExternalBearerAuth),
ChatgptRefresher(Arc<dyn ExternalAuthRefresher>),
}
impl Debug for ExternalAuth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Bearer(_) => f
.debug_tuple("ExternalAuth::Bearer")
.field(&"present")
.finish(),
Self::ChatgptRefresher(_) => f
.debug_tuple("ExternalAuth::ChatgptRefresher")
.field(&"present")
.finish(),
}
}
}
impl Debug for CachedAuth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CachedAuth")
@@ -860,10 +881,6 @@ impl Debug for CachedAuth {
"auth_mode",
&self.auth.as_ref().map(CodexAuth::api_auth_mode),
)
.field(
"external_refresher",
&self.external_refresher.as_ref().map(|_| "present"),
)
.field(
"permanent_refresh_failure",
&self
@@ -907,9 +924,14 @@ enum UnauthorizedRecoveryMode {
// 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 ChatGPT auth tokens (chatgptAuthTokens), UnauthorizedRecovery does not touch disk or refresh
// tokens locally. Instead it calls the ExternalAuthRefresher (account/chatgptAuthTokens/refresh) to ask the
// parent app for new tokens, stores them in the ephemeral auth store, and retries once.
// For external auth sources, UnauthorizedRecovery retries once.
//
// - External ChatGPT auth tokens (`chatgptAuthTokens`) are refreshed by asking
// the parent app for new tokens through the configured
// `ExternalAuthRefresher`, persisting them in the ephemeral auth store, and
// reloading the cached auth snapshot.
// - External bearer auth sources for custom model providers rerun the provider
// auth command without touching disk.
pub struct UnauthorizedRecovery {
manager: Arc<AuthManager>,
step: UnauthorizedRecoveryStep,
@@ -932,9 +954,10 @@ 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 cached_auth
.as_ref()
.is_some_and(CodexAuth::is_external_chatgpt_tokens)
let mode = if manager.has_external_bearer_auth()
|| cached_auth
.as_ref()
.is_some_and(CodexAuth::is_external_chatgpt_tokens)
{
UnauthorizedRecoveryMode::External
} else {
@@ -953,6 +976,10 @@ impl UnauthorizedRecovery {
}
pub fn has_next(&self) -> bool {
if self.manager.has_external_bearer_auth() {
return !matches!(self.step, UnauthorizedRecoveryStep::Done);
}
if !self
.manager
.auth_cached()
@@ -963,7 +990,7 @@ impl UnauthorizedRecovery {
}
if self.mode == UnauthorizedRecoveryMode::External
&& !self.manager.has_external_auth_refresher()
&& !self.manager.has_external_chatgpt_auth_refresher()
{
return false;
}
@@ -972,6 +999,14 @@ impl UnauthorizedRecovery {
}
pub fn unavailable_reason(&self) -> &'static str {
if self.manager.has_external_bearer_auth() {
return if matches!(self.step, UnauthorizedRecoveryStep::Done) {
"recovery_exhausted"
} else {
"ready"
};
}
if !self
.manager
.auth_cached()
@@ -982,7 +1017,7 @@ impl UnauthorizedRecovery {
}
if self.mode == UnauthorizedRecoveryMode::External
&& !self.manager.has_external_auth_refresher()
&& !self.manager.has_external_chatgpt_auth_refresher()
{
return "no_external_refresher";
}
@@ -1085,6 +1120,7 @@ pub struct AuthManager {
auth_credentials_store_mode: AuthCredentialsStoreMode,
forced_chatgpt_workspace_id: RwLock<Option<String>>,
refresh_lock: AsyncMutex<()>,
external_auth: RwLock<Option<ExternalAuth>>,
}
impl AuthManager {
@@ -1108,13 +1144,13 @@ impl AuthManager {
codex_home,
inner: RwLock::new(CachedAuth {
auth: managed_auth,
external_refresher: None,
permanent_refresh_failure: None,
}),
enable_codex_api_key_env,
auth_credentials_store_mode,
forced_chatgpt_workspace_id: RwLock::new(None),
refresh_lock: AsyncMutex::new(()),
external_auth: RwLock::new(None),
}
}
@@ -1122,7 +1158,6 @@ impl AuthManager {
pub fn from_auth_for_testing(auth: CodexAuth) -> Arc<Self> {
let cached = CachedAuth {
auth: Some(auth),
external_refresher: None,
permanent_refresh_failure: None,
};
@@ -1133,6 +1168,7 @@ impl AuthManager {
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
forced_chatgpt_workspace_id: RwLock::new(None),
refresh_lock: AsyncMutex::new(()),
external_auth: RwLock::new(None),
})
}
@@ -1140,7 +1176,6 @@ impl AuthManager {
pub fn from_auth_for_testing_with_home(auth: CodexAuth, codex_home: PathBuf) -> Arc<Self> {
let cached = CachedAuth {
auth: Some(auth),
external_refresher: None,
permanent_refresh_failure: None,
};
Arc::new(Self {
@@ -1150,6 +1185,22 @@ impl AuthManager {
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
forced_chatgpt_workspace_id: RwLock::new(None),
refresh_lock: AsyncMutex::new(()),
external_auth: RwLock::new(None),
})
}
pub fn external_bearer_only(config: ModelProviderAuthInfo) -> Arc<Self> {
Arc::new(Self {
codex_home: PathBuf::from("non-existent"),
inner: RwLock::new(CachedAuth {
auth: None,
permanent_refresh_failure: None,
}),
enable_codex_api_key_env: false,
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
forced_chatgpt_workspace_id: RwLock::new(None),
refresh_lock: AsyncMutex::new(()),
external_auth: RwLock::new(Some(ExternalAuth::Bearer(ExternalBearerAuth::new(config)))),
})
}
@@ -1172,6 +1223,10 @@ impl AuthManager {
/// For stale managed ChatGPT auth, first performs a guarded reload and then
/// refreshes only if the on-disk auth is unchanged.
pub async fn auth(&self) -> Option<CodexAuth> {
if let Some(auth) = self.resolve_external_bearer_auth().await {
return Some(auth);
}
let auth = self.auth_cached()?;
if Self::is_stale_for_proactive_refresh(&auth)
&& let Err(err) = self.refresh_token().await
@@ -1291,15 +1346,15 @@ impl AuthManager {
}
}
pub fn set_external_auth_refresher(&self, refresher: Arc<dyn ExternalAuthRefresher>) {
if let Ok(mut guard) = self.inner.write() {
guard.external_refresher = Some(refresher);
pub fn set_external_chatgpt_auth_refresher(&self, refresher: Arc<dyn ExternalAuthRefresher>) {
if let Ok(mut guard) = self.external_auth.write() {
*guard = Some(ExternalAuth::ChatgptRefresher(refresher));
}
}
pub fn clear_external_auth_refresher(&self) {
if let Ok(mut guard) = self.inner.write() {
guard.external_refresher = None;
pub fn clear_external_chatgpt_auth_refresher(&self) {
if let Ok(mut guard) = self.external_auth.write() {
*guard = None;
}
}
@@ -1316,15 +1371,15 @@ impl AuthManager {
.and_then(|guard| guard.clone())
}
pub fn has_external_auth_refresher(&self) -> bool {
self.inner
pub fn has_external_chatgpt_auth_refresher(&self) -> bool {
self.external_auth
.read()
.ok()
.map(|guard| guard.external_refresher.is_some())
.map(|guard| matches!(guard.as_ref(), Some(ExternalAuth::ChatgptRefresher(_))))
.unwrap_or(false)
}
pub fn is_external_auth_active(&self) -> bool {
pub fn is_external_chatgpt_auth_active(&self) -> bool {
self.auth_cached()
.as_ref()
.is_some_and(CodexAuth::is_external_chatgpt_tokens)
@@ -1347,10 +1402,50 @@ impl AuthManager {
))
}
pub fn shared_with_external_chatgpt_auth_refresher(
codex_home: PathBuf,
enable_codex_api_key_env: bool,
auth_credentials_store_mode: AuthCredentialsStoreMode,
refresher: Arc<dyn ExternalAuthRefresher>,
) -> Arc<Self> {
let manager = Self::shared(
codex_home,
enable_codex_api_key_env,
auth_credentials_store_mode,
);
manager.set_external_chatgpt_auth_refresher(refresher);
manager
}
pub fn unauthorized_recovery(self: &Arc<Self>) -> UnauthorizedRecovery {
UnauthorizedRecovery::new(Arc::clone(self))
}
fn external_auth(&self) -> Option<ExternalAuth> {
self.external_auth
.read()
.ok()
.and_then(|guard| guard.clone())
}
fn has_external_bearer_auth(&self) -> bool {
matches!(self.external_auth(), Some(ExternalAuth::Bearer(_)))
}
async fn resolve_external_bearer_auth(&self) -> Option<CodexAuth> {
let ExternalAuth::Bearer(bearer_auth) = self.external_auth()? else {
return None;
};
match bearer_auth.resolve_access_token().await {
Ok(access_token) => Some(CodexAuth::from_api_key(&access_token)),
Err(err) => {
tracing::error!("Failed to resolve external bearer auth: {err}");
None
}
}
}
/// Attempt to refresh the token by first performing a guarded reload. Auth
/// is reloaded from storage only when the account id matches the currently
/// cached account id. If the persisted token differs from the cached token, we
@@ -1439,10 +1534,16 @@ impl AuthManager {
}
pub fn get_api_auth_mode(&self) -> Option<ApiAuthMode> {
if self.has_external_bearer_auth() {
return Some(ApiAuthMode::ApiKey);
}
self.auth_cached().as_ref().map(CodexAuth::api_auth_mode)
}
pub fn auth_mode(&self) -> Option<crate::AuthMode> {
if self.has_external_bearer_auth() {
return Some(crate::AuthMode::ApiKey);
}
self.auth_cached().as_ref().map(CodexAuth::auth_mode)
}
@@ -1472,17 +1573,15 @@ impl AuthManager {
&self,
reason: ExternalAuthRefreshReason,
) -> Result<(), RefreshTokenError> {
let forced_chatgpt_workspace_id = self.forced_chatgpt_workspace_id();
let refresher = match self.inner.read() {
Ok(guard) => guard.external_refresher.clone(),
Err(_) => {
return Err(RefreshTokenError::Transient(std::io::Error::other(
"failed to read external auth state",
)));
}
};
if let Some(ExternalAuth::Bearer(bearer_auth)) = self.external_auth() {
return bearer_auth
.refresh_after_unauthorized()
.await
.map_err(RefreshTokenError::Transient);
}
let Some(refresher) = refresher else {
let forced_chatgpt_workspace_id = self.forced_chatgpt_workspace_id();
let Some(ExternalAuth::ChatgptRefresher(refresher)) = self.external_auth() else {
return Err(RefreshTokenError::Transient(std::io::Error::other(
"external auth refresher is not configured",
)));
+1
View File
@@ -3,6 +3,7 @@ pub mod error;
mod storage;
mod util;
mod external_bearer;
mod manager;
pub use error::RefreshTokenFailedError;
+78
View File
@@ -1,6 +1,9 @@
use codex_utils_absolute_path::AbsolutePathBuf;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use std::num::NonZeroU64;
use std::time::Duration;
use strum_macros::Display;
use strum_macros::EnumIter;
use ts_rs::TS;
@@ -261,6 +264,81 @@ pub enum ForcedLoginMethod {
Api,
}
const DEFAULT_PROVIDER_AUTH_TIMEOUT_MS: u64 = 5_000;
const DEFAULT_PROVIDER_AUTH_REFRESH_INTERVAL_MS: u64 = 300_000;
/// Configuration for obtaining a provider bearer token from a command.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ModelProviderAuthInfo {
/// Command to execute. Bare names are resolved via `PATH`; paths are resolved against `cwd`.
pub command: String,
/// Command arguments.
#[serde(default)]
pub args: Vec<String>,
/// Maximum time to wait for the token command to exit successfully.
#[serde(default = "default_provider_auth_timeout_ms")]
pub timeout_ms: NonZeroU64,
/// Maximum age for the cached token before rerunning the command.
#[serde(default = "default_provider_auth_refresh_interval_ms")]
pub refresh_interval_ms: NonZeroU64,
/// Working directory used when running the token command.
#[serde(default = "default_provider_auth_cwd")]
#[schemars(skip_serializing_if = "is_default_provider_auth_cwd")]
pub cwd: AbsolutePathBuf,
}
impl ModelProviderAuthInfo {
pub fn timeout(&self) -> Duration {
Duration::from_millis(self.timeout_ms.get())
}
pub fn refresh_interval(&self) -> Duration {
Duration::from_millis(self.refresh_interval_ms.get())
}
}
fn default_provider_auth_timeout_ms() -> NonZeroU64 {
non_zero_u64(
DEFAULT_PROVIDER_AUTH_TIMEOUT_MS,
"model_providers.<id>.auth.timeout_ms",
)
}
fn default_provider_auth_refresh_interval_ms() -> NonZeroU64 {
non_zero_u64(
DEFAULT_PROVIDER_AUTH_REFRESH_INTERVAL_MS,
"model_providers.<id>.auth.refresh_interval_ms",
)
}
fn non_zero_u64(value: u64, field_name: &str) -> NonZeroU64 {
match NonZeroU64::new(value) {
Some(value) => value,
None => panic!("{field_name} must be non-zero"),
}
}
fn default_provider_auth_cwd() -> AbsolutePathBuf {
let deserializer = serde::de::value::StrDeserializer::<serde::de::value::Error>::new(".");
if let Ok(cwd) = AbsolutePathBuf::deserialize(deserializer) {
return cwd;
}
match AbsolutePathBuf::current_dir() {
Ok(cwd) => cwd,
Err(err) => panic!("provider auth cwd must resolve: {err}"),
}
}
fn is_default_provider_auth_cwd(path: &AbsolutePathBuf) -> bool {
path == &default_provider_auth_cwd()
}
/// Represents the trust level for a project directory.
/// This determines the approval policy and sandbox mode applied.
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)]