mirror of
https://github.com/openai/codex.git
synced 2026-09-20 12:47:38 +00:00
Add OAuth credential management for model provider gateways (#46318)
## What changed - Export `GatewayAuthConfig` and `GatewayAuthManager` with PKCE browser sign-in, loopback callbacks, cached token resolution, and refresh after expiry or rejection. - Store gateway credentials in a dedicated encrypted namespace with an independent keyring key. Serialize token exchanges and persistence across processes, preserve refresh rotations after caller cancellation, and retain pending credentials when saving fails. - Validate OAuth endpoints and token responses, disable token-request redirects and logging, and redact sensitive error details. ## Testing Add tests covering browser authorization, callback state validation and cleanup, concurrent refreshes, cancellation, failed-save recovery, storage isolation, endpoint validation, and credential redaction. GitOrigin-RevId: e0c17f1eab7acca378a14b2d00b80940d8542e47
This commit is contained in:
@@ -381,7 +381,7 @@ fn assert_keyring_saved_auth_and_removed_fallback(
|
||||
mock_keyring.saved_value(&old_key).is_none(),
|
||||
"legacy keyring auth entry should not be used"
|
||||
);
|
||||
let secrets_key = compute_keyring_account(codex_home);
|
||||
let secrets_key = compute_keyring_account(codex_home, LocalSecretsNamespace::CodexAuth);
|
||||
assert!(
|
||||
mock_keyring.saved_value(&secrets_key).is_some(),
|
||||
"secrets backend should persist an encryption passphrase in the keyring"
|
||||
@@ -576,7 +576,10 @@ fn factory_uses_secrets_backend_only_when_requested() -> anyhow::Result<()> {
|
||||
secrets_storage.save(&secrets_auth)?;
|
||||
assert!(
|
||||
secrets_keyring
|
||||
.saved_value(&compute_keyring_account(secrets_home.path()))
|
||||
.saved_value(&compute_keyring_account(
|
||||
secrets_home.path(),
|
||||
LocalSecretsNamespace::CodexAuth,
|
||||
))
|
||||
.is_some()
|
||||
);
|
||||
assert!(encrypted_auth_file(secrets_home.path()).exists());
|
||||
@@ -724,7 +727,7 @@ fn auto_auth_storage_load_falls_back_when_keyring_errors() -> anyhow::Result<()>
|
||||
Arc::new(mock_keyring.clone()),
|
||||
AuthKeyringBackendKind::Secrets,
|
||||
);
|
||||
let key = compute_keyring_account(codex_home.path());
|
||||
let key = compute_keyring_account(codex_home.path(), LocalSecretsNamespace::CodexAuth);
|
||||
|
||||
let encrypted = auth_with_prefix("encrypted");
|
||||
seed_secrets_backend_with_auth(&mock_keyring, codex_home.path(), &encrypted)?;
|
||||
@@ -766,7 +769,7 @@ fn auto_auth_storage_save_falls_back_when_keyring_errors() -> anyhow::Result<()>
|
||||
Arc::new(mock_keyring.clone()),
|
||||
AuthKeyringBackendKind::Secrets,
|
||||
);
|
||||
let key = compute_keyring_account(codex_home.path());
|
||||
let key = compute_keyring_account(codex_home.path(), LocalSecretsNamespace::CodexAuth);
|
||||
mock_keyring.set_error(&key, KeyringError::Invalid("error".into(), "save".into()));
|
||||
|
||||
let auth = auth_with_prefix("fallback");
|
||||
|
||||
469
codex-rs/login/src/gateway_auth.rs
Normal file
469
codex-rs/login/src/gateway_auth.rs
Normal file
@@ -0,0 +1,469 @@
|
||||
//! Owns gateway credentials and coordinates refresh and browser login through shared OAuth operations.
|
||||
//! Rotated credentials survive caller cancellation and remain pending until persistence succeeds.
|
||||
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::oauth::AuthorizationCodeGrant;
|
||||
use crate::oauth::AuthorizationRequest;
|
||||
use crate::oauth::ErrorBodyLimit;
|
||||
use crate::oauth::OAuthClient;
|
||||
use crate::oauth::OAuthError;
|
||||
use crate::oauth::RefreshTokenGrant;
|
||||
use crate::oauth::TokenEncoding;
|
||||
use crate::oauth::TokenEndpoint;
|
||||
use crate::oauth::build_authorization_url;
|
||||
use crate::oauth::generate_pkce;
|
||||
use crate::oauth::generate_state;
|
||||
use chrono::Utc;
|
||||
use codex_http_client::ClientRouteClass;
|
||||
use codex_http_client::HttpClient;
|
||||
use codex_http_client::HttpClientBuilder;
|
||||
use codex_http_client::HttpClientFactory;
|
||||
use codex_keyring_store::KeyringStore;
|
||||
use http::StatusCode;
|
||||
use sha2::Digest;
|
||||
use sha2::Sha256;
|
||||
use tokio::sync::Mutex;
|
||||
use url::Host;
|
||||
use url::Url;
|
||||
|
||||
#[path = "gateway_auth_callback.rs"]
|
||||
mod callback;
|
||||
#[path = "gateway_auth_storage.rs"]
|
||||
mod storage;
|
||||
#[path = "gateway_auth_token.rs"]
|
||||
mod token;
|
||||
|
||||
use callback::CallbackListener;
|
||||
use storage::GatewayAuthStorage;
|
||||
use token::StoredToken;
|
||||
use token::TokenResponse;
|
||||
|
||||
const REFRESH_SKEW_SECONDS: i64 = 30;
|
||||
const HTTP_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 20);
|
||||
|
||||
/// Public-client OAuth settings for a model provider.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct GatewayAuthConfig {
|
||||
pub authorization_url: String,
|
||||
pub token_url: String,
|
||||
pub client_id: String,
|
||||
pub resource: Option<String>,
|
||||
pub scopes: Vec<String>,
|
||||
pub redirect_port: Option<u16>,
|
||||
}
|
||||
|
||||
/// Resolves and persists an OAuth access token for a model provider.
|
||||
#[derive(Clone)]
|
||||
pub struct GatewayAuthManager {
|
||||
state: Arc<GatewayAuthState>,
|
||||
}
|
||||
|
||||
enum RefreshPolicy {
|
||||
WhenExpired,
|
||||
AfterRejection(String),
|
||||
}
|
||||
|
||||
impl RefreshPolicy {
|
||||
fn can_reuse(&self, token: &StoredToken) -> bool {
|
||||
token_is_usable(token)
|
||||
&& match self {
|
||||
Self::WhenExpired => true,
|
||||
Self::AfterRejection(rejected_access_token) => {
|
||||
token.access_token != *rejected_access_token
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum RefreshOutcome {
|
||||
AccessToken(String),
|
||||
Authorize,
|
||||
}
|
||||
|
||||
struct GatewayAuthState {
|
||||
config: GatewayAuthConfig,
|
||||
codex_home: PathBuf,
|
||||
storage: GatewayAuthStorage,
|
||||
http_client: HttpClient,
|
||||
cached_token: Arc<Mutex<GatewayAuthCache>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct GatewayAuthCache {
|
||||
token: Option<StoredToken>,
|
||||
// Retain rotated credentials across a failed save. The prior persisted token remains
|
||||
// available for comparison so retrying cannot overwrite a newer external login.
|
||||
pending: Option<StoredToken>,
|
||||
}
|
||||
|
||||
impl GatewayAuthManager {
|
||||
/// Creates independent gateway credentials in their encrypted store using the caller's HTTP policy.
|
||||
/// Token grants never follow redirects or include primary-provider credentials or request logs.
|
||||
pub fn new(
|
||||
config: GatewayAuthConfig,
|
||||
codex_home: PathBuf,
|
||||
http_client_factory: &HttpClientFactory,
|
||||
keyring: Arc<dyn KeyringStore>,
|
||||
) -> io::Result<Self> {
|
||||
let http_client = HttpClientBuilder::new()
|
||||
.without_redirects()
|
||||
.without_request_logging()
|
||||
.build_respecting_outbound_proxy_policy(
|
||||
http_client_factory,
|
||||
&config.token_url,
|
||||
ClientRouteClass::Auth,
|
||||
)
|
||||
.map_err(|_| io::Error::other("failed to create provider OAuth HTTP client"))?;
|
||||
Ok(Self {
|
||||
state: Arc::new(GatewayAuthState {
|
||||
config,
|
||||
storage: GatewayAuthStorage::new(codex_home.clone(), keyring),
|
||||
codex_home,
|
||||
http_client,
|
||||
cached_token: Arc::new(Mutex::new(GatewayAuthCache::default())),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns a cached access token or refreshes/authorizes when it is no longer usable.
|
||||
pub async fn resolve_access_token(&self) -> io::Result<String> {
|
||||
self.resolve(RefreshPolicy::WhenExpired).await
|
||||
}
|
||||
|
||||
/// Recovers after a request rejects `rejected_access_token`, reusing a usable replacement
|
||||
/// from storage or refreshing/authorizing when necessary. Pass the access token used by the
|
||||
/// failed request; callers must bound retries and decide whether the request is safe to replay.
|
||||
pub async fn refresh_access_token(&self, rejected_access_token: &str) -> io::Result<String> {
|
||||
self.resolve(RefreshPolicy::AfterRejection(
|
||||
rejected_access_token.to_owned(),
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn resolve(&self, policy: RefreshPolicy) -> io::Result<String> {
|
||||
validate_config(&self.state.config)?;
|
||||
let mut cached = Arc::clone(&self.state.cached_token).lock_owned().await;
|
||||
if cached.token.is_none() && cached.pending.is_none() {
|
||||
cached.token = self.load_token()?;
|
||||
}
|
||||
if cached.pending.is_none()
|
||||
&& matches!(policy, RefreshPolicy::WhenExpired)
|
||||
&& let Some(token) = cached.token.as_ref()
|
||||
&& token_is_usable(token)
|
||||
{
|
||||
return Ok(token.access_token.clone());
|
||||
}
|
||||
// The provider may rotate its token before the HTTP response arrives. Keep the
|
||||
// refresh, persistence, and cache update alive if the caller drops this future.
|
||||
let manager = self.clone();
|
||||
let (mut cached, result) = tokio::spawn(async move {
|
||||
let result = manager.refresh(&mut cached, &policy).await;
|
||||
(cached, result)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| io::Error::other("provider OAuth refresh task failed"))?;
|
||||
match result? {
|
||||
RefreshOutcome::AccessToken(access_token) => Ok(access_token),
|
||||
RefreshOutcome::Authorize => self.authorize(&mut cached).await,
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_pending(&self, cached: &mut GatewayAuthCache) -> io::Result<String> {
|
||||
let token = cached
|
||||
.pending
|
||||
.as_ref()
|
||||
.ok_or_else(|| io::Error::other("provider OAuth credentials are missing"))?;
|
||||
self.save_token(token)?;
|
||||
let access_token = token.access_token.clone();
|
||||
cached.token = cached.pending.take();
|
||||
Ok(access_token)
|
||||
}
|
||||
|
||||
async fn refresh(
|
||||
&self,
|
||||
cached: &mut GatewayAuthCache,
|
||||
policy: &RefreshPolicy,
|
||||
) -> io::Result<RefreshOutcome> {
|
||||
let _credential_lock = storage::lock_credentials(&self.state.codex_home).await?;
|
||||
// Recovery always rereads under the cross-process lock before choosing a token.
|
||||
// Even a replacement from storage must differ from the token rejected by this request.
|
||||
for _ in 0..2 {
|
||||
let stored = self.load_token()?;
|
||||
if stored != cached.token {
|
||||
cached.token = stored;
|
||||
cached.pending = None;
|
||||
}
|
||||
if cached.pending.is_some() {
|
||||
self.persist_pending(cached)?;
|
||||
}
|
||||
if let Some(token) = cached.token.as_ref()
|
||||
&& policy.can_reuse(token)
|
||||
{
|
||||
return Ok(RefreshOutcome::AccessToken(token.access_token.clone()));
|
||||
}
|
||||
let Some(refresh_token) = cached
|
||||
.token
|
||||
.as_ref()
|
||||
.and_then(|token| token.refresh_token.as_deref())
|
||||
else {
|
||||
break;
|
||||
};
|
||||
match self
|
||||
.oauth()
|
||||
.refresh::<TokenResponse>(RefreshTokenGrant {
|
||||
refresh_token,
|
||||
resource: self.state.config.resource.as_deref(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
cached.pending = Some(response.into_stored(Some(refresh_token))?);
|
||||
return self
|
||||
.persist_pending(cached)
|
||||
.map(RefreshOutcome::AccessToken);
|
||||
}
|
||||
Err(OAuthError::Rejected(rejection))
|
||||
if rejection.status == StatusCode::BAD_REQUEST
|
||||
&& matches!(
|
||||
rejection.detail.error_code(),
|
||||
Some(
|
||||
"invalid_grant" | "unauthorized_client" | "unsupported_grant_type"
|
||||
)
|
||||
) =>
|
||||
{
|
||||
// Some public clients receive refresh tokens despite being unable to use
|
||||
// that grant. Reauthorize after explicit rejection without disabling refresh.
|
||||
// Also recover updates from clients that predate the credential lock.
|
||||
if self.load_token()? == cached.token {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(token::endpoint_error(
|
||||
error,
|
||||
&self.state.config,
|
||||
"refresh_token",
|
||||
/*redirect_uri*/ None,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let stored = self.load_token()?;
|
||||
if stored != cached.token {
|
||||
cached.token = stored;
|
||||
cached.pending = None;
|
||||
if let Some(token) = cached.token.as_ref()
|
||||
&& policy.can_reuse(token)
|
||||
{
|
||||
return Ok(RefreshOutcome::AccessToken(token.access_token.clone()));
|
||||
}
|
||||
}
|
||||
Ok(RefreshOutcome::Authorize)
|
||||
}
|
||||
|
||||
fn credential_id(&self) -> String {
|
||||
let config = &self.state.config;
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(self.state.codex_home.to_string_lossy().as_bytes());
|
||||
digest.update([0]);
|
||||
for value in [
|
||||
config.authorization_url.as_str(),
|
||||
config.token_url.as_str(),
|
||||
config.client_id.as_str(),
|
||||
config.resource.as_deref().unwrap_or_default(),
|
||||
] {
|
||||
digest.update(value.as_bytes());
|
||||
digest.update([0]);
|
||||
}
|
||||
for scope in &config.scopes {
|
||||
digest.update(scope.as_bytes());
|
||||
digest.update([0]);
|
||||
}
|
||||
format!("provider-oauth|{:x}", digest.finalize())
|
||||
}
|
||||
|
||||
fn load_token(&self) -> io::Result<Option<StoredToken>> {
|
||||
self.state
|
||||
.storage
|
||||
.load(&self.credential_id())?
|
||||
.map(|value| {
|
||||
serde_json::from_str(&value)
|
||||
.map_err(|_| io::Error::other("stored provider OAuth credentials are invalid"))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn save_token(&self, token: &StoredToken) -> io::Result<()> {
|
||||
let value = serde_json::to_string(token)
|
||||
.map_err(|_| io::Error::other("failed to encode provider OAuth credentials"))?;
|
||||
self.state.storage.save(&self.credential_id(), &value)
|
||||
}
|
||||
|
||||
async fn authorize(&self, cached: &mut GatewayAuthCache) -> io::Result<String> {
|
||||
self.authorize_with_browser(cached, |authorization_url| {
|
||||
eprintln!("Authorize the model provider by opening this URL:\n{authorization_url}\n");
|
||||
if webbrowser::open(authorization_url.as_str()).is_err() {
|
||||
eprintln!("Browser launch failed; open the URL above manually.");
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn authorize_with_browser(
|
||||
&self,
|
||||
cached: &mut GatewayAuthCache,
|
||||
open_browser: impl FnOnce(&Url),
|
||||
) -> io::Result<String> {
|
||||
let pkce = generate_pkce();
|
||||
let state = generate_state();
|
||||
let mut listener = CallbackListener::new(self.state.config.redirect_port, state.clone())?;
|
||||
let redirect_uri = listener.redirect_uri().to_string();
|
||||
let scope = self.state.config.scopes.join(" ");
|
||||
let authorization_url = build_authorization_url(AuthorizationRequest {
|
||||
endpoint: &self.state.config.authorization_url,
|
||||
client_id: &self.state.config.client_id,
|
||||
redirect_uri: &redirect_uri,
|
||||
scope: (!scope.is_empty()).then_some(scope.as_str()),
|
||||
resource: self.state.config.resource.as_deref(),
|
||||
pkce: &pkce,
|
||||
state: &state,
|
||||
extra_parameters: &[],
|
||||
})
|
||||
.map_err(|_| io::Error::other("invalid provider OAuth authorization endpoint"))?;
|
||||
|
||||
open_browser(&authorization_url);
|
||||
|
||||
let code = listener.wait().await?;
|
||||
drop(listener);
|
||||
|
||||
// Wait for user interaction without the store lock, then serialize issuance and
|
||||
// persistence with refreshes. Use the current stored token as the failed-save baseline.
|
||||
let _credential_lock = storage::lock_credentials(&self.state.codex_home).await?;
|
||||
cached.token = self.load_token()?;
|
||||
let response = self
|
||||
.oauth()
|
||||
.exchange_code::<TokenResponse>(AuthorizationCodeGrant {
|
||||
code: &code,
|
||||
redirect_uri: &redirect_uri,
|
||||
pkce: &pkce,
|
||||
resource: self.state.config.resource.as_deref(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
token::endpoint_error(
|
||||
error,
|
||||
&self.state.config,
|
||||
"authorization_code",
|
||||
Some(&redirect_uri),
|
||||
)
|
||||
})?;
|
||||
cached.pending = Some(response.into_stored(/*previous_refresh_token*/ None)?);
|
||||
self.persist_pending(cached)
|
||||
}
|
||||
|
||||
fn oauth(&self) -> OAuthClient<'_> {
|
||||
OAuthClient::new(
|
||||
&self.state.http_client,
|
||||
TokenEndpoint {
|
||||
url: &self.state.config.token_url,
|
||||
client_id: &self.state.config.client_id,
|
||||
encoding: TokenEncoding::Form,
|
||||
timeout: Some(HTTP_TIMEOUT),
|
||||
error_body_limit: ErrorBodyLimit::Bytes(8 * 1024),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for GatewayAuthConfig {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
// Configured URLs may contain issuer-specific credentials in arbitrary query keys.
|
||||
formatter
|
||||
.debug_struct("GatewayAuthConfig")
|
||||
.field("client_id", &self.client_id)
|
||||
.field("scopes", &self.scopes)
|
||||
.field("redirect_port", &self.redirect_port)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for GatewayAuthManager {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("GatewayAuthManager")
|
||||
.field("config", &self.state.config)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
fn token_is_usable(token: &StoredToken) -> bool {
|
||||
token
|
||||
.expires_at
|
||||
.is_none_or(|expires_at| expires_at > Utc::now().timestamp() + REFRESH_SKEW_SECONDS)
|
||||
}
|
||||
|
||||
fn validate_config(config: &GatewayAuthConfig) -> io::Result<()> {
|
||||
let authorization = validate_oauth_url(
|
||||
&config.authorization_url,
|
||||
"provider OAuth authorization endpoint",
|
||||
)?;
|
||||
if authorization.query_pairs().any(|(name, _)| {
|
||||
matches!(
|
||||
name.as_ref(),
|
||||
"response_type"
|
||||
| "client_id"
|
||||
| "redirect_uri"
|
||||
| "state"
|
||||
| "scope"
|
||||
| "resource"
|
||||
| "code_challenge"
|
||||
| "code_challenge_method"
|
||||
)
|
||||
}) {
|
||||
return Err(io::Error::other(
|
||||
"provider OAuth authorization endpoint cannot include OAuth request parameters",
|
||||
));
|
||||
}
|
||||
validate_oauth_url(&config.token_url, "provider OAuth token endpoint")?;
|
||||
if config.client_id.trim().is_empty() {
|
||||
return Err(io::Error::other(
|
||||
"provider OAuth client ID must not be empty",
|
||||
));
|
||||
}
|
||||
if config.redirect_port == Some(0) {
|
||||
return Err(io::Error::other(
|
||||
"provider OAuth redirect port must not be zero",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_oauth_url(value: &str, description: &str) -> io::Result<Url> {
|
||||
let url = Url::parse(value).map_err(|_| io::Error::other(format!("invalid {description}")))?;
|
||||
if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() {
|
||||
return Err(io::Error::other(format!(
|
||||
"{description} cannot include embedded credentials or fragments"
|
||||
)));
|
||||
}
|
||||
let is_loopback = match url.host() {
|
||||
Some(Host::Ipv4(address)) => address.is_loopback(),
|
||||
Some(Host::Ipv6(address)) => address.is_loopback(),
|
||||
Some(Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
|
||||
None => false,
|
||||
};
|
||||
if url.scheme() != "https" && !(url.scheme() == "http" && is_loopback) {
|
||||
return Err(io::Error::other(format!(
|
||||
"{description} must use HTTPS unless it is loopback"
|
||||
)));
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "gateway_auth_tests.rs"]
|
||||
mod tests;
|
||||
115
codex-rs/login/src/gateway_auth_callback.rs
Normal file
115
codex-rs/login/src/gateway_auth_callback.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
//! Owns the loopback listener lifecycle; OAuth callback parsing and state validation are shared.
|
||||
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::oauth::CallbackError;
|
||||
use crate::oauth::CallbackParameters;
|
||||
use tiny_http::Response;
|
||||
use tiny_http::Server;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::time::timeout;
|
||||
use url::Url;
|
||||
|
||||
const BROWSER_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 180);
|
||||
|
||||
pub(super) struct CallbackListener {
|
||||
server: Arc<Server>,
|
||||
receiver: oneshot::Receiver<io::Result<String>>,
|
||||
redirect_uri: String,
|
||||
}
|
||||
|
||||
impl CallbackListener {
|
||||
pub(super) fn new(redirect_port: Option<u16>, expected_state: String) -> io::Result<Self> {
|
||||
let callback_address = format!("127.0.0.1:{}", redirect_port.unwrap_or_default());
|
||||
let server =
|
||||
Arc::new(Server::http(callback_address).map_err(|_| {
|
||||
io::Error::other("failed to bind provider OAuth loopback callback")
|
||||
})?);
|
||||
let redirect_uri = match server.server_addr() {
|
||||
tiny_http::ListenAddr::IP(address) => format!("http://{address}/callback"),
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
_ => return Err(io::Error::other("invalid provider OAuth loopback address")),
|
||||
};
|
||||
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
let callback_server = Arc::clone(&server);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
while let Ok(request) = callback_server.recv() {
|
||||
let Ok(callback) = Url::parse(&format!("http://127.0.0.1{}", request.url())) else {
|
||||
let _ = request.respond(
|
||||
Response::from_string("Invalid OAuth callback")
|
||||
.with_status_code(/*code*/ 400),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
if callback.path() != "/callback" {
|
||||
let _ = request
|
||||
.respond(Response::from_string("Not found").with_status_code(/*code*/ 404));
|
||||
continue;
|
||||
}
|
||||
|
||||
let params = CallbackParameters::from_url(&callback);
|
||||
let result = match params.validate(&expected_state) {
|
||||
Ok(code) => Ok(code.to_string()),
|
||||
Err(CallbackError::StateMismatch) => {
|
||||
let _ = request.respond(
|
||||
Response::from_string("OAuth callback state did not match")
|
||||
.with_status_code(/*code*/ 400),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(CallbackError::Provider { .. }) => Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"provider OAuth authorization was denied",
|
||||
)),
|
||||
Err(CallbackError::MissingCode) => {
|
||||
Err(io::Error::other("provider OAuth callback omitted its code"))
|
||||
}
|
||||
};
|
||||
let mut response = if result.is_ok() {
|
||||
Response::from_string(
|
||||
"<!doctype html><html><body><p>Sign-in complete. You may close this window.</p><script>window.close()</script></body></html>",
|
||||
)
|
||||
} else {
|
||||
Response::from_string("Sign-in failed.").with_status_code(/*code*/ 400)
|
||||
};
|
||||
if result.is_ok()
|
||||
&& let Ok(header) = tiny_http::Header::from_bytes(
|
||||
&b"Content-Type"[..],
|
||||
&b"text/html; charset=utf-8"[..],
|
||||
)
|
||||
{
|
||||
response.add_header(header);
|
||||
}
|
||||
let _ = request.respond(response);
|
||||
let _ = sender.send(result);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
server,
|
||||
receiver,
|
||||
redirect_uri,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn redirect_uri(&self) -> &str {
|
||||
&self.redirect_uri
|
||||
}
|
||||
|
||||
pub(super) async fn wait(&mut self) -> io::Result<String> {
|
||||
timeout(BROWSER_TIMEOUT, &mut self.receiver)
|
||||
.await
|
||||
.map_err(|_| io::Error::other("timed out waiting for provider OAuth sign-in"))?
|
||||
.map_err(|_| io::Error::other("provider OAuth sign-in was cancelled"))?
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CallbackListener {
|
||||
fn drop(&mut self) {
|
||||
self.server.unblock();
|
||||
}
|
||||
}
|
||||
83
codex-rs/login/src/gateway_auth_storage.rs
Normal file
83
codex-rs/login/src/gateway_auth_storage.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
//! Persists gateway credentials and serializes exchanges across all configurations in one store.
|
||||
|
||||
use std::fs::File;
|
||||
use std::fs::OpenOptions;
|
||||
use std::fs::TryLockError;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_keyring_store::KeyringStore;
|
||||
use codex_secrets::LocalSecretsNamespace;
|
||||
use codex_secrets::SecretName;
|
||||
use codex_secrets::SecretScope;
|
||||
use codex_secrets::SecretsBackendKind;
|
||||
use codex_secrets::SecretsManager;
|
||||
|
||||
pub(super) async fn lock_credentials(codex_home: &Path) -> io::Result<File> {
|
||||
let directory = codex_home.join("secrets");
|
||||
std::fs::create_dir_all(&directory)?;
|
||||
// Configurations have separate credential entries but rewrite the same encrypted file.
|
||||
// Keep one stable sidecar locked from the initial read through token exchange and save.
|
||||
let path = directory.join("gateway_oauth.lock");
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(path)?;
|
||||
tokio::time::timeout(Duration::from_secs(/*secs*/ 60), async {
|
||||
loop {
|
||||
match file.try_lock() {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(TryLockError::WouldBlock) => {
|
||||
tokio::time::sleep(Duration::from_millis(/*millis*/ 50)).await
|
||||
}
|
||||
Err(error) => return Err(io::Error::from(error)),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"timed out waiting for provider OAuth credentials",
|
||||
)
|
||||
})??;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
pub(super) struct GatewayAuthStorage(SecretsManager);
|
||||
|
||||
impl GatewayAuthStorage {
|
||||
pub(super) fn new(codex_home: PathBuf, keyring: Arc<dyn KeyringStore>) -> Self {
|
||||
Self(SecretsManager::new_with_keyring_store_and_namespace(
|
||||
codex_home,
|
||||
SecretsBackendKind::Local,
|
||||
keyring,
|
||||
LocalSecretsNamespace::GatewayOAuth,
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn load(&self, credential_id: &str) -> io::Result<Option<String>> {
|
||||
self.0
|
||||
.get(&SecretScope::Global, &secret_name(credential_id)?)
|
||||
.map_err(|_| io::Error::other("failed to load provider OAuth credentials"))
|
||||
}
|
||||
|
||||
pub(super) fn save(&self, credential_id: &str, value: &str) -> io::Result<()> {
|
||||
self.0
|
||||
.set(&SecretScope::Global, &secret_name(credential_id)?, value)
|
||||
.map_err(|_| io::Error::other("failed to save provider OAuth credentials"))
|
||||
}
|
||||
}
|
||||
|
||||
fn secret_name(credential_id: &str) -> io::Result<SecretName> {
|
||||
let digest = credential_id
|
||||
.strip_prefix("provider-oauth|")
|
||||
.ok_or_else(|| io::Error::other("invalid provider OAuth credential account"))?;
|
||||
SecretName::new(&format!("PROVIDER_OAUTH_{}", digest.to_ascii_uppercase()))
|
||||
.map_err(io::Error::other)
|
||||
}
|
||||
1096
codex-rs/login/src/gateway_auth_tests.rs
Normal file
1096
codex-rs/login/src/gateway_auth_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
147
codex-rs/login/src/gateway_auth_token.rs
Normal file
147
codex-rs/login/src/gateway_auth_token.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
//! Validates gateway token lifetimes and renders bounded diagnostics from shared OAuth errors.
|
||||
|
||||
use std::io;
|
||||
|
||||
use crate::oauth::OAuthError;
|
||||
use crate::oauth::sanitize_url_for_logging;
|
||||
use chrono::Utc;
|
||||
use codex_secrets::redact_secrets;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use super::GatewayAuthConfig;
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub(super) struct StoredToken {
|
||||
pub access_token: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub refresh_token: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct TokenResponse {
|
||||
access_token: String,
|
||||
#[serde(default)]
|
||||
token_type: Option<String>,
|
||||
#[serde(default)]
|
||||
refresh_token: Option<String>,
|
||||
#[serde(default)]
|
||||
expires_in: Option<u64>,
|
||||
}
|
||||
|
||||
impl TokenResponse {
|
||||
pub(super) fn into_stored(
|
||||
self,
|
||||
previous_refresh_token: Option<&str>,
|
||||
) -> io::Result<StoredToken> {
|
||||
if self.access_token.trim().is_empty() {
|
||||
return Err(io::Error::other(
|
||||
"provider OAuth token response omitted its access token",
|
||||
));
|
||||
}
|
||||
if self
|
||||
.token_type
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.eq_ignore_ascii_case("bearer"))
|
||||
{
|
||||
return Err(io::Error::other(
|
||||
"provider OAuth token response returned an unsupported token type",
|
||||
));
|
||||
}
|
||||
if self.expires_in == Some(0) {
|
||||
return Err(io::Error::other(
|
||||
"provider OAuth token response returned a zero lifetime",
|
||||
));
|
||||
}
|
||||
let expires_at = self
|
||||
.expires_in
|
||||
.map(|expires_in| {
|
||||
let expires_in = i64::try_from(expires_in)
|
||||
.map_err(|_| io::Error::other("provider OAuth token lifetime is too large"))?;
|
||||
Utc::now()
|
||||
.timestamp()
|
||||
.checked_add(expires_in)
|
||||
.ok_or_else(|| io::Error::other("provider OAuth token expiry overflows"))
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(StoredToken {
|
||||
access_token: self.access_token,
|
||||
refresh_token: self
|
||||
.refresh_token
|
||||
.or_else(|| previous_refresh_token.map(str::to_string)),
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn endpoint_error(
|
||||
error: OAuthError,
|
||||
config: &GatewayAuthConfig,
|
||||
grant_type: &str,
|
||||
redirect_uri: Option<&str>,
|
||||
) -> io::Error {
|
||||
let endpoint = diagnostic_url(&config.token_url);
|
||||
match error {
|
||||
OAuthError::Rejected(rejection) => {
|
||||
// Issuers can echo custom URL credentials that shared grant redaction does not know.
|
||||
// Request IDs are already truncated, so even replacing complete values is unsafe.
|
||||
let has_query = std::iter::once(config.token_url.as_str())
|
||||
.chain(config.resource.as_deref())
|
||||
.any(|value| value.contains('?'));
|
||||
let (detail, request_id) = if has_query {
|
||||
(
|
||||
"provider response details omitted".to_string(),
|
||||
String::new(),
|
||||
)
|
||||
} else {
|
||||
// The shared layer redacts complete grant credentials before display limits.
|
||||
let detail: String = redact_secrets(rejection.detail.to_string())
|
||||
.chars()
|
||||
.take(/*n*/ 512)
|
||||
.collect();
|
||||
let request_id = rejection
|
||||
.request_id
|
||||
.map(|value| format!(" (request id: {value})"))
|
||||
.unwrap_or_default();
|
||||
(detail, request_id)
|
||||
};
|
||||
let client_id = &config.client_id;
|
||||
let resource = config
|
||||
.resource
|
||||
.as_deref()
|
||||
.map(diagnostic_url)
|
||||
.unwrap_or_else(|| "<not sent>".to_string());
|
||||
let redirect_uri = redirect_uri.unwrap_or("<not sent>");
|
||||
let scopes = if config.scopes.is_empty() {
|
||||
"<not sent>".to_string()
|
||||
} else {
|
||||
config.scopes.join(" ")
|
||||
};
|
||||
let pkce = if grant_type == "authorization_code" {
|
||||
"S256"
|
||||
} else {
|
||||
"not applicable"
|
||||
};
|
||||
io::Error::other(format!(
|
||||
"provider OAuth token endpoint {endpoint} returned HTTP {} - {detail}{request_id}. Request: grant_type={grant_type}, client_id={client_id}, client_auth=none (public client), pkce={pkce}, redirect_uri={redirect_uri}, resource={resource}, authorization_scopes={scopes}",
|
||||
rejection.status.as_u16(),
|
||||
))
|
||||
}
|
||||
OAuthError::Transport(_) => io::Error::other(format!(
|
||||
"provider OAuth token exchange failed for {endpoint}"
|
||||
)),
|
||||
OAuthError::InvalidResponse => io::Error::other("provider OAuth token response is invalid"),
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostic_url(value: &str) -> String {
|
||||
// Issuers may use custom query keys for credentials that the shared allowlist cannot know.
|
||||
let sanitized = sanitize_url_for_logging(value);
|
||||
sanitized
|
||||
.split('?')
|
||||
.next()
|
||||
.unwrap_or("<invalid-url>")
|
||||
.to_string()
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub use auth::WorkspaceRoutingSession;
|
||||
|
||||
mod callback_params;
|
||||
mod device_code_auth;
|
||||
mod gateway_auth;
|
||||
mod oauth;
|
||||
mod outbound_proxy;
|
||||
mod pkce;
|
||||
@@ -72,3 +73,6 @@ pub use auth_env_telemetry::AuthEnvTelemetry;
|
||||
pub use auth_env_telemetry::collect_auth_env_telemetry;
|
||||
pub use outbound_proxy::AuthRouteConfig;
|
||||
pub use token_data::TokenData;
|
||||
|
||||
pub use gateway_auth::GatewayAuthConfig;
|
||||
pub use gateway_auth::GatewayAuthManager;
|
||||
|
||||
@@ -41,10 +41,6 @@ impl fmt::Debug for OAuthError {
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum ErrorBodyLimit {
|
||||
Unlimited,
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(dead_code, reason = "Used by GatewayAuthManager in the following PR")
|
||||
)]
|
||||
Bytes(usize),
|
||||
}
|
||||
|
||||
|
||||
@@ -1526,7 +1526,7 @@ mod tests {
|
||||
let env = TempCodexHome::new();
|
||||
let store = MockKeyringStore::default();
|
||||
store.set_error(
|
||||
&compute_keyring_account(env.path()),
|
||||
&compute_keyring_account(env.path(), LocalSecretsNamespace::McpOAuth),
|
||||
KeyringError::Invalid("error".into(), "save".into()),
|
||||
);
|
||||
let tokens = sample_tokens();
|
||||
|
||||
@@ -179,8 +179,9 @@ pub fn environment_id_from_cwd(cwd: &Path) -> String {
|
||||
format!("cwd-{short}")
|
||||
}
|
||||
|
||||
/// Computes the OS keyring account name used to store the local secrets passphrase.
|
||||
pub fn compute_keyring_account(codex_home: &Path) -> String {
|
||||
/// Computes the OS keyring account name used to store a local namespace's passphrase.
|
||||
/// Existing namespaces retain their shared key; gateway credentials use an independent key.
|
||||
pub fn compute_keyring_account(codex_home: &Path, namespace: LocalSecretsNamespace) -> String {
|
||||
let canonical = codex_home
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| codex_home.to_path_buf())
|
||||
@@ -191,7 +192,15 @@ pub fn compute_keyring_account(codex_home: &Path) -> String {
|
||||
let digest = hasher.finalize();
|
||||
let hex = format!("{digest:x}");
|
||||
let short = hex.get(..16).unwrap_or(hex.as_str());
|
||||
format!("secrets|{short}")
|
||||
let home_account = format!("secrets|{short}");
|
||||
// Separate keys also prevent concurrent first writes to the gateway and primary
|
||||
// stores from overwriting each other's newly generated encryption key.
|
||||
match namespace {
|
||||
LocalSecretsNamespace::GatewayOAuth => format!("{home_account}|gateway-oauth"),
|
||||
LocalSecretsNamespace::ManagedSecrets
|
||||
| LocalSecretsNamespace::CodexAuth
|
||||
| LocalSecretsNamespace::McpOAuth => home_account,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn keyring_service() -> &'static str {
|
||||
|
||||
@@ -41,6 +41,7 @@ const SECRETS_VERSION: u8 = 1;
|
||||
const LOCAL_SECRETS_FILENAME: &str = "local.age";
|
||||
const CODEX_AUTH_SECRETS_FILENAME: &str = "codex_auth.age";
|
||||
const MCP_OAUTH_SECRETS_FILENAME: &str = "mcp_oauth.age";
|
||||
const GATEWAY_OAUTH_SECRETS_FILENAME: &str = "gateway_oauth.age";
|
||||
static MCP_OAUTH_CACHE: Mutex<Option<CachedMcpSecrets>> = Mutex::new(None);
|
||||
|
||||
/// Selects the local encrypted file used by a `LocalSecretsBackend`.
|
||||
@@ -53,6 +54,8 @@ pub enum LocalSecretsNamespace {
|
||||
CodexAuth,
|
||||
/// OAuth credentials for external MCP servers.
|
||||
McpOAuth,
|
||||
/// Gateway OAuth credentials, isolated from primary auth in file and encryption key.
|
||||
GatewayOAuth,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -156,6 +159,7 @@ impl LocalSecretsBackend {
|
||||
LocalSecretsNamespace::ManagedSecrets => LOCAL_SECRETS_FILENAME,
|
||||
LocalSecretsNamespace::CodexAuth => CODEX_AUTH_SECRETS_FILENAME,
|
||||
LocalSecretsNamespace::McpOAuth => MCP_OAUTH_SECRETS_FILENAME,
|
||||
LocalSecretsNamespace::GatewayOAuth => GATEWAY_OAUTH_SECRETS_FILENAME,
|
||||
};
|
||||
self.secrets_dir().join(filename)
|
||||
}
|
||||
@@ -235,7 +239,7 @@ impl LocalSecretsBackend {
|
||||
}
|
||||
|
||||
fn load_or_create_passphrase(&self) -> Result<SecretString> {
|
||||
let account = compute_keyring_account(&self.codex_home);
|
||||
let account = compute_keyring_account(&self.codex_home, self.namespace);
|
||||
let loaded = self
|
||||
.keyring_store
|
||||
.load(keyring_service(), &account)
|
||||
@@ -449,7 +453,8 @@ mod tests {
|
||||
fn set_fails_when_keyring_is_unavailable() -> Result<()> {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let keyring = Arc::new(MockKeyringStore::default());
|
||||
let account = compute_keyring_account(codex_home.path());
|
||||
let account =
|
||||
compute_keyring_account(codex_home.path(), LocalSecretsNamespace::ManagedSecrets);
|
||||
keyring.set_error(
|
||||
&account,
|
||||
KeyringError::Invalid("error".into(), "load".into()),
|
||||
@@ -517,14 +522,20 @@ mod tests {
|
||||
);
|
||||
let mcp_backend = LocalSecretsBackend::new_with_namespace(
|
||||
codex_home.path().to_path_buf(),
|
||||
keyring,
|
||||
keyring.clone(),
|
||||
LocalSecretsNamespace::McpOAuth,
|
||||
);
|
||||
let gateway_backend = LocalSecretsBackend::new_with_namespace(
|
||||
codex_home.path().to_path_buf(),
|
||||
keyring.clone(),
|
||||
LocalSecretsNamespace::GatewayOAuth,
|
||||
);
|
||||
let scope = SecretScope::Global;
|
||||
let name = SecretName::new("TEST_SECRET")?;
|
||||
|
||||
codex_auth_backend.set(&scope, &name, "codex-auth-value")?;
|
||||
mcp_backend.set(&scope, &name, "mcp-value")?;
|
||||
gateway_backend.set(&scope, &name, "gateway-value")?;
|
||||
|
||||
assert_eq!(
|
||||
codex_auth_backend.get(&scope, &name)?,
|
||||
@@ -556,6 +567,23 @@ mod tests {
|
||||
.exists()
|
||||
);
|
||||
assert!(!codex_home.path().join("secrets").join("local.age").exists());
|
||||
assert!(
|
||||
codex_home
|
||||
.path()
|
||||
.join("secrets/gateway_oauth.age")
|
||||
.is_file()
|
||||
);
|
||||
// Primary-auth key removal must not make the independent gateway file unreadable.
|
||||
keyring
|
||||
.delete(
|
||||
keyring_service(),
|
||||
&compute_keyring_account(codex_home.path(), LocalSecretsNamespace::CodexAuth),
|
||||
)
|
||||
.expect("remove primary secrets key");
|
||||
assert_eq!(
|
||||
gateway_backend.get(&scope, &name)?,
|
||||
Some("gateway-value".to_string())
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user