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
parent ea650a91b3
commit 0071968829
7 changed files with 544 additions and 45 deletions
+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)]