Add device-code authorization for gateway OAuth (#59822)

## Why

Gateway sign-in needs an alternative for terminals that cannot open a browser.

## What changed

- Add optional `gateway_oauth.device_authorization_url` configuration and a `login_with_device_code` API that presents a verification URL and user code, polls for approval, and persists the resulting credentials.
- Use the configured proxy and network policy, validate verification URLs, and bound OAuth responses. Support cancellation and report denied or expired codes without exposing issuer error details.

## Testing

Add tests for successful authorization and credential persistence, unsafe verification URLs, network-policy denials, denied and expired codes, and cancellation during approval or credential storage.

GitOrigin-RevId: 96398578fb866d34929e85c04eadf468d8cd3255
This commit is contained in:
smurching-oai
2026-11-06 04:24:04 +00:00
committed by copyberry
parent aece559654
commit df38cb3b2a
22 changed files with 568 additions and 8 deletions
+1
View File
@@ -4067,6 +4067,7 @@ dependencies = [
"http 1.4.0",
"jsonwebtoken",
"keyring",
"oauth2",
"once_cell",
"opentelemetry_sdk",
"os_info",
+4
View File
@@ -2127,6 +2127,10 @@
},
"description": "Omit to send the gateway token via the Authorization header"
},
"device_authorization_url": {
"description": "Optional device endpoint used when the terminal cannot open a browser for gateway sign-in.",
"type": "string"
},
"redirect_port": {
"format": "uint16",
"minimum": 0.0,
+1
View File
@@ -30,6 +30,7 @@ pub mod code_mode_host;
pub mod context_snapshot;
pub mod exec_server;
pub mod hooks;
pub mod oauth;
pub mod process;
pub mod responses;
pub mod runtime_auth;
+36
View File
@@ -0,0 +1,36 @@
//! Mock OAuth endpoints for device sign-in tests.
use serde_json::json;
use wiremock::Mock;
use wiremock::MockGuard;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::body_string_contains;
use wiremock::matchers::method;
use wiremock::matchers::path;
/// Issues a challenge and answers one token poll. The guard lets a test wait for that poll.
pub async fn mount_device_code_grant(
server: &MockServer,
token_response: ResponseTemplate,
) -> MockGuard {
Mock::given(method("POST"))
.and(path("/devicecode"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"device_code": "secret-device-code", "user_code": "ABCD-EFGH",
"verification_uri": "https://login.example/device", "expires_in": 60, "interval": 1,
})))
.expect(1)
.mount(server)
.await;
Mock::given(method("POST"))
.and(path("/token"))
.and(body_string_contains(
"grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code",
))
.and(body_string_contains("device_code=secret-device-code"))
.respond_with(token_response)
.expect(1)
.mount_as_scoped(server)
.await
}
@@ -101,6 +101,7 @@ async fn runtime_harness(
fn gateway_config(server: &MockServer, delivery: GatewayOAuthDelivery) -> GatewayOAuthConfig {
GatewayOAuthConfig::OAuth(GatewayOAuthSettings {
authorization_url: format!("{}/authorize", server.uri()),
device_authorization_url: None,
token_url: format!("{}/token", server.uri()),
client_id: "client".into(),
resource: None,
+1
View File
@@ -84,6 +84,7 @@ pub use crate::transport::ByteStream;
pub use crate::transport::HttpTransport;
pub use crate::transport::ReqwestTransport;
pub use crate::transport::StreamResponse;
pub use crate::transport::bounded_response_bytes;
#[cfg(windows)]
mod windows_proxy_auth;
+1 -1
View File
@@ -297,7 +297,7 @@ fn bounded_response_stream(
Ok(Box::pin(stream))
}
async fn bounded_response_bytes(
pub async fn bounded_response_bytes(
response: HttpResponse,
max_bytes: usize,
) -> Result<Bytes, TransportError> {
+1
View File
@@ -25,6 +25,7 @@ codex-utils-path = { workspace = true }
codex-utils-template = { workspace = true }
codex-workload-identity = { workspace = true }
http = { workspace = true }
oauth2 = { version = "5", default-features = false }
once_cell = { workspace = true }
os_info = { workspace = true }
rand = { workspace = true }
+1
View File
@@ -78,6 +78,7 @@ pub use auth::read_openai_api_key_from_env;
pub use auth::save_auth;
pub use auth_env_telemetry::AuthEnvTelemetry;
pub use auth_env_telemetry::collect_auth_env_telemetry;
pub use oauth::OAuthDeviceCodePrompt;
pub use outbound_proxy::AuthRouteConfig;
pub use token_data::TokenData;
+178
View File
@@ -0,0 +1,178 @@
//! Standard device authorization through `oauth2`, using Codex's configured HTTP transport.
//! Callers own prompts, cancellation, and credential persistence. Issuer errors stay redacted.
use std::fmt;
use std::future::Future;
use std::io;
use std::pin::Pin;
use std::time::Duration;
use codex_http_client::ClientRouteClass;
use codex_http_client::HttpClientFactory;
use codex_http_client::NetworkPolicyDenied;
use codex_http_client::RouteAwareClientPool;
use codex_http_client::bounded_response_bytes;
use codex_http_client::validate_endpoint_url;
use oauth2::AsyncHttpClient;
use oauth2::ClientId;
use oauth2::DeviceAuthorizationUrl;
use oauth2::DeviceCodeErrorResponseType;
use oauth2::RequestTokenError;
use oauth2::Scope;
use oauth2::StandardDeviceAuthorizationResponse;
use oauth2::TokenUrl;
use oauth2::basic::BasicClient;
use oauth2::basic::BasicTokenResponse;
use url::Url;
// OAuth responses contain credentials, not arbitrary downloads.
const MAX_OAUTH_RESPONSE_BYTES: usize = 64 * 1024;
/// The URL and short code a user needs to approve a device authorization request.
pub struct OAuthDeviceCodePrompt {
pub verification_uri: Url,
pub user_code: String,
}
impl fmt::Debug for OAuthDeviceCodePrompt {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OAuthDeviceCodePrompt")
.finish_non_exhaustive()
}
}
pub(crate) struct DeviceAuthorizationOptions<'a> {
pub device_url: &'a str,
pub token_url: &'a str,
pub client_id: &'a str,
pub scopes: &'a [String],
pub request_timeout: Duration,
}
#[derive(Clone, Copy, Debug, thiserror::Error)]
pub(crate) enum DeviceAuthorizationError {
#[error("Device authorization expired; start sign-in again")]
Expired,
#[error("Device authorization was denied; start sign-in again when ready")]
Denied,
#[error("The gateway returned an invalid device verification URL")]
InvalidVerificationUrl,
#[error(transparent)]
Policy(NetworkPolicyDenied),
}
// Adapts oauth2's async HTTP interface to Codex's proxy and network-policy-aware client.
// Like oauth2's reqwest adapter, this only translates requests and responses:
// https://github.com/ramosbugs/oauth2-rs/blob/f3424b4b2190c83c6d031fdc71eed2351d49e0df/src/reqwest_client.rs#L6-L38
struct DeviceHttpClient {
client: RouteAwareClientPool,
request_timeout: Duration,
}
impl<'c> AsyncHttpClient<'c> for DeviceHttpClient {
type Error = io::Error;
type Future =
Pin<Box<dyn Future<Output = Result<oauth2::HttpResponse, Self::Error>> + Send + 'c>>;
fn call(&'c self, request: oauth2::HttpRequest) -> Self::Future {
Box::pin(async move {
let (parts, body) = request.into_parts();
let response = self
.client
.request(parts.method, parts.uri.to_string())
.headers(parts.headers)
.body(body)
.timeout(self.request_timeout)
.send()
.await
.map_err(|_| io::Error::other("OAuth HTTP request failed"))?;
let status = response.status();
let headers = response.headers().clone();
let body = bounded_response_bytes(response, MAX_OAUTH_RESPONSE_BYTES)
.await
.map_err(|_| io::Error::other("OAuth HTTP response could not be read"))?;
let mut response = oauth2::HttpResponse::new(body.to_vec());
*response.status_mut() = status;
*response.headers_mut() = headers;
Ok(response)
})
}
}
pub(crate) async fn authorize_device(
factory: &HttpClientFactory,
options: DeviceAuthorizationOptions<'_>,
present: impl FnOnce(&OAuthDeviceCodePrompt),
) -> io::Result<BasicTokenResponse> {
let oauth = BasicClient::new(ClientId::new(options.client_id.to_owned()))
.set_device_authorization_url(
DeviceAuthorizationUrl::new(options.device_url.to_owned())
.map_err(|_| io::Error::other("Invalid device authorization URL"))?,
)
.set_token_uri(
TokenUrl::new(options.token_url.to_owned())
.map_err(|_| io::Error::other("Invalid OAuth token URL"))?,
);
let http = DeviceHttpClient {
client: RouteAwareClientPool::new_without_redirects_or_request_logging(
factory.clone(),
ClientRouteClass::Auth,
),
request_timeout: options.request_timeout,
};
// oauth2 retries transport errors while polling. Report network policy denials immediately.
let network_policy_denial = |denial| {
io::Error::new(
io::ErrorKind::PermissionDenied,
DeviceAuthorizationError::Policy(denial),
)
};
let permit_for = |url| {
factory
.network_policy()
.acquire(url)
.map_err(network_policy_denial)
};
let device_permit = permit_for(oauth.device_authorization_url().url())?;
let request = oauth
.exchange_device_code()
.add_scopes(options.scopes.iter().cloned().map(Scope::new));
let authorization: StandardDeviceAuthorizationResponse = device_permit
.run(request.request_async(&http))
.await
.map_err(network_policy_denial)?
.map_err(|_| io::Error::other("Device authorization request failed"))?;
let verification_uri = validate_endpoint_url(
authorization.verification_uri().url().as_str(),
"device verification URL",
)
.map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
DeviceAuthorizationError::InvalidVerificationUrl,
)
})?;
let token_permit = permit_for(oauth.token_uri().url())?;
present(&OAuthDeviceCodePrompt {
verification_uri,
user_code: authorization.user_code().secret().clone(),
});
let poll = oauth.exchange_device_access_token(&authorization);
token_permit
.run(poll.request_async(&http, tokio::time::sleep, /*timeout*/ None))
.await
.map_err(network_policy_denial)?
.map_err(|error| match error {
RequestTokenError::ServerResponse(response) => match response.error() {
DeviceCodeErrorResponseType::AccessDenied => io::Error::new(
io::ErrorKind::PermissionDenied,
DeviceAuthorizationError::Denied,
),
DeviceCodeErrorResponseType::ExpiredToken => {
io::Error::new(io::ErrorKind::TimedOut, DeviceAuthorizationError::Expired)
}
_ => io::Error::other("Device authorization failed"),
},
_ => io::Error::other("Device authorization failed"),
})
}
+5
View File
@@ -5,6 +5,7 @@
mod authorization;
mod client;
mod device;
mod diagnostics;
mod error;
mod pkce;
@@ -20,6 +21,10 @@ pub(crate) use client::RefreshReason;
pub(crate) use client::RefreshTokenGrant;
pub(crate) use client::TokenEncoding;
pub(crate) use client::TokenEndpoint;
pub(crate) use device::DeviceAuthorizationError;
pub(crate) use device::DeviceAuthorizationOptions;
pub use device::OAuthDeviceCodePrompt;
pub(crate) use device::authorize_device;
pub(crate) use diagnostics::sanitize_url_for_logging;
pub(crate) use error::ErrorBodyLimit;
pub(crate) use error::OAuthError;
+3
View File
@@ -42,6 +42,7 @@ mod storage;
#[path = "oauth_session_token.rs"]
mod token;
pub use crate::oauth::OAuthDeviceCodePrompt;
pub use codex_model_provider_info::OAuthSessionConfig;
pub use login::OAuthLoginControl;
pub use login::OAuthSessionStatus;
@@ -107,6 +108,7 @@ struct OAuthSessionState {
codex_home: PathBuf,
storage: OAuthSessionStorage,
http_client: HttpClient,
http_client_factory: HttpClientFactory,
cached_token: Arc<Mutex<OAuthSessionCache>>,
// Reserve explicit login independently of background credential reads and refreshes.
login_attempt: Arc<Mutex<()>>,
@@ -155,6 +157,7 @@ impl OAuthSessionManager {
status_tx: login::status_sender_for_host(&codex_home, http_client_factory),
codex_home,
http_client,
http_client_factory: http_client_factory.clone(),
cached_token: Arc::new(Mutex::new(OAuthSessionCache::default())),
login_attempt: Arc::new(Mutex::new(())),
status: tokio::sync::watch::channel(/*init*/ None).0,
@@ -0,0 +1,156 @@
//! Exercises the device protocol at its HTTP boundary and the shared credential lifecycle.
use super::*;
use core_test_support::oauth::mount_device_code_grant;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn device_login_presents_prompt_and_persists_grant() {
let server = MockServer::start().await;
let _token_request = mount_device_code_grant(&server, approved_device_tokens()).await;
let (client, _home) = client(device_config(&server), Default::default());
client
.save_token(&StoredToken {
authorization_id: Some("previous-grant".into()),
access_token: "previous-access".into(),
refresh_token: None,
expires_at: None,
})
.unwrap();
let mut shown = None;
client
.login_with_device_code(std::future::pending(), |prompt| {
shown = Some((
prompt.verification_uri.to_string(),
prompt.user_code.clone(),
));
})
.await
.unwrap();
assert_eq!(
shown,
Some(("https://login.example/device".into(), "ABCD-EFGH".into()))
);
let requests = server.received_requests().await.unwrap();
let challenge = url::form_urlencoded::parse(&requests[0].body).collect::<HashMap<_, _>>();
assert_eq!(challenge["client_id"], "codex-test");
assert_eq!(challenge["scope"], "openid gateway.inference");
let stored = client.load_token().unwrap().unwrap();
assert_eq!(
(
stored.access_token.as_str(),
stored.refresh_token.as_deref()
),
("device-access", Some("device-refresh"))
);
let sign_in_id = stored.authorization_id.expect("saved sign-in ID");
assert_ne!(sign_in_id, "previous-grant");
}
async fn mount_device_challenge(server: &MockServer, verification_uri: &str) {
Mock::given(method("POST"))
.and(path("/devicecode"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"device_code": "secret-device-code", "user_code": "ABCD-EFGH",
"verification_uri": verification_uri, "expires_in": 60,
})))
.expect(1)
.mount(server)
.await;
}
#[tokio::test]
async fn device_login_rejects_an_insecure_verification_link_before_showing_the_code() {
let server = MockServer::start().await;
mount_device_challenge(&server, "http://sso.example/activate").await;
let (client, _home) = client(device_config(&server), Default::default());
let error = client
.login_with_device_code(std::future::pending(), |_| {
panic!("unsafe verification link was shown")
})
.await
.unwrap_err();
assert_eq!(
error.to_string(),
"The gateway returned an invalid device verification URL"
);
}
#[tokio::test]
async fn device_login_reports_network_policy_denial_for_either_endpoint() {
let server = MockServer::start().await;
mount_device_challenge(&server, "https://sso.example/activate").await;
let config = device_config(&server);
for allowed in [
config.token_url.as_str(),
config.device_authorization_url.as_deref().unwrap(),
] {
let policy = codex_http_client::NetworkPolicy::unmanaged()
.restrict_to_endpoints([url::Url::parse(allowed).unwrap()].into());
let factory = transport_default_auth_route_config()
.http_client_factory()
.clone()
.with_network_policy(policy);
let home = tempfile::tempdir().unwrap();
let client = OAuthSessionManager::new(
config.clone(),
home.path().to_path_buf(),
&factory,
Arc::new(MockKeyringStore::default()),
)
.unwrap();
let error = client
.login_with_device_code(std::future::pending(), |_| {
panic!("code shown for a blocked login")
})
.await
.unwrap_err();
assert_eq!(
error.to_string(),
codex_http_client::NetworkPolicyDenied::Destination.to_string()
);
}
}
#[tokio::test]
async fn denied_or_expired_codes_explain_why_sign_in_stopped() {
for (code, expected) in [("access_denied", "denied"), ("expired_token", "expired")] {
let server = MockServer::start().await;
let response = ResponseTemplate::new(400)
.set_body_json(json!({"error": code, "error_description": "secret-device-code"}));
let _token_request = mount_device_code_grant(&server, response).await;
let (client, _home) = client(device_config(&server), Default::default());
let error = client
.login_with_device_code(std::future::pending(), |_| {})
.await
.unwrap_err();
assert!(error.to_string().contains(expected));
assert!(!format!("{error:?}").contains("secret-device-code"));
}
}
#[tokio::test]
async fn device_login_can_be_canceled_while_waiting_for_approval() {
let server = MockServer::start().await;
let pending = ResponseTemplate::new(400)
.set_body_json(json!({"error": "authorization_pending"}))
.set_delay(Duration::from_secs(30));
let token_request = mount_device_code_grant(&server, pending).await;
let (client, home) = client(device_config(&server), Default::default());
let cancel = async {
token_request.wait_until_satisfied().await;
// Another Codex process can use the credential store while the user decides.
let _store = super::super::storage::lock_credentials(home.path())
.await
.unwrap();
};
let result = tokio::time::timeout(
Duration::from_secs(15),
client.login_with_device_code(cancel, |_| {}),
)
.await
.expect("cancel should finish while the issuer is still waiting");
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::Interrupted);
}
+78 -1
View File
@@ -3,6 +3,8 @@
//! Readiness I/O leaves the cache unlocked and preserves newer state.
use super::*;
use crate::oauth::DeviceAuthorizationOptions;
use crate::oauth::authorize_device;
use std::future::Future;
use std::sync::OnceLock;
use std::sync::atomic::AtomicBool;
@@ -10,6 +12,11 @@ use std::sync::atomic::Ordering;
use tokio::sync::OwnedMutexGuard;
use tokio::sync::broadcast;
enum LoginMethod<B, D> {
Browser(B),
DeviceCode { endpoint: String, present: D },
}
/// Readiness and browser authorization progress for a gateway credential.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum OAuthSessionStatus {
@@ -320,6 +327,70 @@ impl OAuthSessionManager {
&self,
cancel: impl Future<Output = ()>,
open_browser: impl FnOnce(&Url),
) -> io::Result<()> {
self.login_with_method(
cancel,
LoginMethod::<_, fn(&OAuthDeviceCodePrompt)>::Browser(open_browser),
)
.await
}
/// Runs RFC 8628 device authorization, exposing only the user-facing code and URL.
pub async fn login_with_device_code(
&self,
cancel: impl Future<Output = ()>,
present: impl FnOnce(&OAuthDeviceCodePrompt),
) -> io::Result<()> {
let device_authorization_url = self.state.config.device_authorization_url.as_deref()
.ok_or_else(|| io::Error::other("The configured gateway does not specify gateway_oauth.device_authorization_url"))?;
validate_oauth_url(
device_authorization_url,
"provider OAuth device authorization endpoint",
)?;
self.login_with_method(
cancel,
LoginMethod::<fn(&Url), _>::DeviceCode {
endpoint: device_authorization_url.to_owned(),
present,
},
)
.await
}
async fn authorize_with_device_code(
&self,
cached: &mut OAuthSessionCache,
endpoint: &str,
previous_token: Option<StoredToken>,
present: impl FnOnce(&OAuthDeviceCodePrompt),
) -> io::Result<()> {
let config = &self.state.config;
let response = authorize_device(
&self.state.http_client_factory,
DeviceAuthorizationOptions {
device_url: endpoint,
token_url: &config.token_url,
client_id: &config.client_id,
scopes: &config.scopes,
request_timeout: HTTP_TIMEOUT,
},
present,
)
.await?;
let token = TokenResponse::from(response).into_stored(/*previous_token*/ None)?;
cached.token = previous_token;
// If saving fails, the next request can retry without asking the user to sign in again.
cached.pending = Some(token);
let credential_lock = Arc::new(storage::lock_credentials(&self.state.codex_home).await?);
cached.token = self.load_token_async().await?;
self.persist_pending(cached, &credential_lock).await?;
Ok(())
}
async fn login_with_method(
&self,
cancel: impl Future<Output = ()>,
method: LoginMethod<impl FnOnce(&Url), impl FnOnce(&OAuthDeviceCodePrompt)>,
) -> io::Result<()> {
validate_config(&self.state.config)?;
let _login_attempt = Arc::clone(&self.state.login_attempt)
@@ -351,12 +422,18 @@ impl OAuthSessionManager {
result = async {
// A blocking read may outlive cancellation; it must not retain the login locks.
guard.credential = self.load_token_async().await?;
self.authorize_with_browser(&mut cached, open_browser).await.map(|_| ())
match method {
LoginMethod::Browser(open_browser) => self.authorize_with_browser(&mut cached, open_browser).await.map(|_| ()),
LoginMethod::DeviceCode { endpoint, present } => self.authorize_with_device_code(&mut cached, &endpoint, guard.credential.clone(), present).await,
}
} => result,
}
.map_err(|error| {
// Issuer errors may echo arbitrary credentials. Only retain safe text in the
// returned error and shared status, which is broadcast and can be debug-printed.
if let Some(device_error) = error.get_ref().and_then(|error| error.downcast_ref::<crate::oauth::DeviceAuthorizationError>()) {
return io::Error::new(error.kind(), *device_error);
}
let message = if error.kind() == io::ErrorKind::Interrupted {
"Gateway sign-in was canceled"
} else {
@@ -3,6 +3,7 @@
use super::*;
use codex_keyring_store::CredentialStoreError;
use codex_keyring_store::KeyringStore;
use core_test_support::oauth::mount_device_code_grant;
use pretty_assertions::assert_eq;
use std::sync::Mutex;
use std::sync::atomic::AtomicBool;
@@ -53,7 +54,9 @@ impl KeyringStore for BlockingKeyring {
}
}
fn blocking_client() -> (OAuthSessionManager, tempfile::TempDir, Arc<BlockingKeyring>) {
fn blocking_client(
configuration: OAuthSessionConfig,
) -> (OAuthSessionManager, tempfile::TempDir, Arc<BlockingKeyring>) {
let home = tempfile::tempdir().unwrap();
let keyring = Arc::new(BlockingKeyring {
inner: MockKeyringStore::default(),
@@ -62,7 +65,7 @@ fn blocking_client() -> (OAuthSessionManager, tempfile::TempDir, Arc<BlockingKey
gate: Mutex::new(/*t*/ None),
});
let manager = OAuthSessionManager::new(
loopback_config(),
configuration,
home.path().to_path_buf(),
transport_default_auth_route_config().http_client_factory(),
keyring.clone(),
@@ -73,7 +76,7 @@ fn blocking_client() -> (OAuthSessionManager, tempfile::TempDir, Arc<BlockingKey
#[tokio::test]
async fn readiness_does_not_block_cached_requests_or_overwrite_newer_credentials() {
let (manager, _home, keyring) = blocking_client();
let (manager, _home, keyring) = blocking_client(loopback_config());
save_token(&manager, "old", /*refresh_token*/ None, i64::MAX);
assert_eq!(manager.resolve_access_token().await.unwrap(), "old");
keyring.check_thread.store(/*val*/ true, Ordering::Relaxed);
@@ -129,7 +132,7 @@ async fn readiness_does_not_block_cached_requests_or_overwrite_newer_credentials
#[tokio::test]
async fn cancel_during_keyring_read_releases_login_and_cached_credentials() {
let (manager, _home, keyring) = blocking_client();
let (manager, _home, keyring) = blocking_client(loopback_config());
save_token(&manager, "existing", /*refresh_token*/ None, i64::MAX);
assert_eq!(manager.resolve_access_token().await.unwrap(), "existing");
let mut events = manager.state.status_tx.subscribe();
@@ -202,3 +205,39 @@ async fn cancel_during_keyring_read_releases_login_and_cached_credentials() {
assert_eq!(error.kind(), std::io::ErrorKind::Interrupted);
release_tx.send(()).unwrap();
}
#[tokio::test]
async fn canceling_while_saving_an_approved_device_login_keeps_it_for_the_next_request() {
let server = MockServer::start().await;
let _token_request = mount_device_code_grant(&server, approved_device_tokens()).await;
let (manager, _home, keyring) = blocking_client(device_config(&server));
save_token(&manager, "existing", /*refresh_token*/ None, i64::MAX);
let (saving, save_started) = tokio::sync::oneshot::channel();
let (resume, paused) = std::sync::mpsc::channel();
let login = manager.login_with_device_code(
async {
save_started
.await
.expect("Codex received the approved tokens")
},
|_| {
// The next keyring read happens after approval, just before saving the tokens.
*keyring.gate.lock().unwrap() = Some((saving, paused));
},
);
let result = tokio::time::timeout(Duration::from_secs(15), login).await;
let _ = resume.send(());
assert_eq!(
result
.expect("cancel should finish while storage is paused")
.unwrap_err()
.kind(),
std::io::ErrorKind::Interrupted,
);
assert_eq!(
manager.resolve_access_token().await.unwrap(),
"device-access"
);
}
+17
View File
@@ -29,6 +29,9 @@ mod login_tests;
#[path = "oauth_session_request_tests.rs"]
mod request_tests;
#[path = "oauth_session_device_tests.rs"]
mod device_tests;
#[path = "oauth_session_storage_tests.rs"]
mod storage_tests;
@@ -61,6 +64,20 @@ fn config(server: &MockServer) -> OAuthSessionConfig {
}
}
fn device_config(server: &MockServer) -> OAuthSessionConfig {
OAuthSessionConfig {
device_authorization_url: Some(format!("{}/devicecode", server.uri())),
..config(server)
}
}
fn approved_device_tokens() -> ResponseTemplate {
ResponseTemplate::new(200).set_body_json(json!({
"access_token": "device-access", "refresh_token": "device-refresh",
"token_type": "Bearer", "expires_in": 3600,
}))
}
fn loopback_config() -> OAuthSessionConfig {
OAuthSessionConfig {
authorization_url: "http://127.0.0.1:18080/authorize".to_string(),
+12
View File
@@ -7,6 +7,7 @@ use crate::oauth::generate_state;
use crate::oauth::sanitize_url_for_logging;
use chrono::Utc;
use codex_secrets::redact_secrets;
use oauth2::TokenResponse as _;
use serde::Deserialize;
use serde::Serialize;
@@ -35,6 +36,17 @@ pub(super) struct TokenResponse {
expires_in: Option<u64>,
}
impl From<oauth2::basic::BasicTokenResponse> for TokenResponse {
fn from(response: oauth2::basic::BasicTokenResponse) -> Self {
Self {
access_token: response.access_token().secret().clone(),
token_type: Some(response.token_type().as_ref().to_owned()),
refresh_token: response.refresh_token().map(|token| token.secret().clone()),
expires_in: response.expires_in().map(|duration| duration.as_secs()),
}
}
}
impl TokenResponse {
pub(super) fn into_stored(
self,
@@ -48,6 +48,9 @@ pub enum GatewayOAuthConfig {
#[serde(deny_unknown_fields)]
pub struct GatewayOAuthSettings {
pub authorization_url: String,
/// Optional device endpoint used when the terminal cannot open a browser for gateway sign-in.
#[serde(skip_serializing_if = "Option::is_none")]
pub device_authorization_url: Option<String>,
pub token_url: String,
pub client_id: String,
pub resource: Option<String>,
@@ -170,6 +173,9 @@ impl GatewayOAuthConfig {
&settings.authorization_url,
"gateway_oauth.authorization_url",
)?;
if let Some(url) = &settings.device_authorization_url {
validate_endpoint_url(url, "gateway_oauth.device_authorization_url")?;
}
validate_endpoint_url(&settings.token_url, "gateway_oauth.token_url")?;
if settings.client_id.trim().is_empty() || settings.redirect_port == Some(0) {
return Err(
@@ -34,6 +34,19 @@ fn parses_header_and_cookie_delivery() {
);
}
#[test]
fn a_remote_device_authorization_url_requires_https() {
for (url, valid) in [
("https://login.example.test/devicecode", true),
("http://login.example.test/devicecode", false),
] {
let config = provider(&format!(
"device_authorization_url = '{url}'\ndelivery = {{ kind = 'header', name = 'x-gateway-auth' }}"
));
assert_eq!(config.validate().is_ok(), valid, "{url}");
}
}
#[test]
fn rejects_unsafe_endpoints_and_delivery_collisions() {
let valid = provider("delivery = { kind = 'header', name = 'x-gateway-auth' }");
+1 -1
View File
@@ -52,7 +52,7 @@ impl ModelProviderSharedState {
) -> std::io::Result<Arc<OAuthSessionManager>> {
let oauth = GatewayAuthConfig {
authorization_url: config.authorization_url.clone(),
device_authorization_url: None,
device_authorization_url: config.device_authorization_url.clone(),
token_url: config.token_url.clone(),
client_id: config.client_id.clone(),
resource: config.resource.clone(),
@@ -73,7 +73,7 @@ pub fn seed_gateway_auth(
let runtime = primary.runtime_config();
let oauth = GatewayAuthConfig {
authorization_url: config.authorization_url,
device_authorization_url: None,
device_authorization_url: config.device_authorization_url,
token_url: config.token_url,
client_id: config.client_id,
resource: config.resource,
@@ -72,6 +72,7 @@ async fn gateway_credentials_accompany_primary_auth_in_models_and_responses() {
] {
let config = GatewayOAuthSettings {
authorization_url: format!("{}/authorize", server.uri()),
device_authorization_url: None,
token_url: format!("{}/token", server.uri()),
client_id: "test".into(),
resource: None,
@@ -219,6 +220,7 @@ async fn gateway_preserves_runtime_auth_and_rejects_header_collisions() {
let info = ModelProviderInfo {
gateway_oauth: Some(GatewayOAuthConfig::OAuth(GatewayOAuthSettings {
authorization_url: format!("{}/authorize", server.uri()),
device_authorization_url: None,
token_url: format!("{}/token", server.uri()),
client_id: "client".into(),
resource: None,
@@ -321,6 +323,7 @@ async fn gateway_refresh_preserves_primary_auth_and_hides_issuer_errors() {
let info = ModelProviderInfo {
gateway_oauth: Some(GatewayOAuthConfig::OAuth(GatewayOAuthSettings {
authorization_url: format!("{}/authorize", server.uri()),
device_authorization_url: None,
token_url: format!("{}/token?credential=query-secret", server.uri()),
client_id: "client".into(),
resource: None,
@@ -416,6 +419,7 @@ async fn models_cache_is_reused_only_for_matching_gateway_configuration() {
)])),
gateway_oauth: Some(GatewayOAuthConfig::OAuth(GatewayOAuthSettings {
authorization_url: format!("{}/authorize", server.uri()),
device_authorization_url: None,
token_url: format!("{}/token", server.uri()),
client_id: "client".into(),
resource: None,
@@ -500,6 +504,7 @@ async fn models_observe_gateway_rotation(instance: ProviderInstance) {
model_catalog_url: Some(format!("{}/models", server.uri()).into()),
gateway_oauth: Some(GatewayOAuthConfig::OAuth(GatewayOAuthSettings {
authorization_url: format!("{}/authorize", server.uri()),
device_authorization_url: None,
token_url: format!("{}/token", server.uri()),
client_id: "client".into(),
resource: None,
@@ -615,6 +620,7 @@ async fn gateway_setup_errors_are_reported_when_authentication_is_requested() {
let mut info = ModelProviderInfo {
gateway_oauth: Some(GatewayOAuthConfig::OAuth(GatewayOAuthSettings {
authorization_url: format!("{}/authorize", server.uri()),
device_authorization_url: None,
token_url: format!("{}/token", server.uri()),
client_id: "client".into(),
resource: None,
@@ -686,6 +692,7 @@ async fn gateway_login_required_invalidates_the_last_successful_catalog() {
model_catalog_url: Some(format!("{}/models", server.uri()).into()),
gateway_oauth: Some(GatewayOAuthConfig::OAuth(GatewayOAuthSettings {
authorization_url: format!("{}/authorize", server.uri()),
device_authorization_url: None,
token_url: format!("{}/token", server.uri()),
client_id: "client".into(),
resource: None,
@@ -771,6 +778,7 @@ async fn gateway_login_states_stop_request_retries() {
let info = ModelProviderInfo {
gateway_oauth: Some(GatewayOAuthConfig::OAuth(GatewayOAuthSettings {
authorization_url: format!("{}/authorize", server.uri()),
device_authorization_url: None,
token_url: format!("{}/token", server.uri()),
client_id: "client".into(),
resource: None,