mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
ea650a91b3
commit
0071968829
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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",
|
||||
)));
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod error;
|
||||
mod storage;
|
||||
mod util;
|
||||
|
||||
mod external_bearer;
|
||||
mod manager;
|
||||
|
||||
pub use error::RefreshTokenFailedError;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user