From df38cb3b2af40dd413d0af7f000fe5cb3f077aad Mon Sep 17 00:00:00 2001 From: smurching-oai Date: Fri, 6 Nov 2026 04:10:47 +0000 Subject: [PATCH] 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 --- codex-rs/Cargo.lock | 1 + codex-rs/core/config.schema.json | 4 + codex-rs/core/tests/common/lib.rs | 1 + codex-rs/core/tests/common/oauth.rs | 36 ++++ .../suite/client_websockets_runtime_auth.rs | 1 + codex-rs/http-client/src/lib.rs | 1 + codex-rs/http-client/src/transport.rs | 2 +- codex-rs/login/Cargo.toml | 1 + codex-rs/login/src/lib.rs | 1 + codex-rs/login/src/oauth/device.rs | 178 ++++++++++++++++++ codex-rs/login/src/oauth/mod.rs | 5 + codex-rs/login/src/oauth_session.rs | 3 + .../login/src/oauth_session_device_tests.rs | 156 +++++++++++++++ codex-rs/login/src/oauth_session_login.rs | 79 +++++++- .../login/src/oauth_session_storage_tests.rs | 47 ++++- codex-rs/login/src/oauth_session_tests.rs | 17 ++ codex-rs/login/src/oauth_session_token.rs | 12 ++ .../model-provider-info/src/gateway_oauth.rs | 6 + .../src/gateway_oauth_tests.rs | 13 ++ codex-rs/model-provider/src/shared_state.rs | 2 +- .../src/shared_state_test_support.rs | 2 +- .../model-provider/src/shared_state_tests.rs | 8 + 22 files changed, 568 insertions(+), 8 deletions(-) create mode 100644 codex-rs/core/tests/common/oauth.rs create mode 100644 codex-rs/login/src/oauth/device.rs create mode 100644 codex-rs/login/src/oauth_session_device_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f324db1723..7a03efc60c 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4067,6 +4067,7 @@ dependencies = [ "http 1.4.0", "jsonwebtoken", "keyring", + "oauth2", "once_cell", "opentelemetry_sdk", "os_info", diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index f605669d6b..a051e5ec94 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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, diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index add2d3a932..babc3ecf42 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -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; diff --git a/codex-rs/core/tests/common/oauth.rs b/codex-rs/core/tests/common/oauth.rs new file mode 100644 index 0000000000..ed13e73f0f --- /dev/null +++ b/codex-rs/core/tests/common/oauth.rs @@ -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 +} diff --git a/codex-rs/core/tests/suite/client_websockets_runtime_auth.rs b/codex-rs/core/tests/suite/client_websockets_runtime_auth.rs index 73c4f8d803..92a66b966c 100644 --- a/codex-rs/core/tests/suite/client_websockets_runtime_auth.rs +++ b/codex-rs/core/tests/suite/client_websockets_runtime_auth.rs @@ -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, diff --git a/codex-rs/http-client/src/lib.rs b/codex-rs/http-client/src/lib.rs index e5007e1c43..47274c8b8f 100644 --- a/codex-rs/http-client/src/lib.rs +++ b/codex-rs/http-client/src/lib.rs @@ -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; diff --git a/codex-rs/http-client/src/transport.rs b/codex-rs/http-client/src/transport.rs index 369d4ea68c..6eb86a3ca7 100644 --- a/codex-rs/http-client/src/transport.rs +++ b/codex-rs/http-client/src/transport.rs @@ -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 { diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml index d9ade5cf5c..03ee83418d 100644 --- a/codex-rs/login/Cargo.toml +++ b/codex-rs/login/Cargo.toml @@ -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 } diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index e52f62e7ca..293037f561 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -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; diff --git a/codex-rs/login/src/oauth/device.rs b/codex-rs/login/src/oauth/device.rs new file mode 100644 index 0000000000..f3c0339f60 --- /dev/null +++ b/codex-rs/login/src/oauth/device.rs @@ -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> + 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 { + 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"), + }) +} diff --git a/codex-rs/login/src/oauth/mod.rs b/codex-rs/login/src/oauth/mod.rs index dc292d267b..9af897bf7b 100644 --- a/codex-rs/login/src/oauth/mod.rs +++ b/codex-rs/login/src/oauth/mod.rs @@ -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; diff --git a/codex-rs/login/src/oauth_session.rs b/codex-rs/login/src/oauth_session.rs index 3e39d336e0..6c6cc81fba 100644 --- a/codex-rs/login/src/oauth_session.rs +++ b/codex-rs/login/src/oauth_session.rs @@ -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>, // Reserve explicit login independently of background credential reads and refreshes. login_attempt: Arc>, @@ -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, diff --git a/codex-rs/login/src/oauth_session_device_tests.rs b/codex-rs/login/src/oauth_session_device_tests.rs new file mode 100644 index 0000000000..ac44caa7ee --- /dev/null +++ b/codex-rs/login/src/oauth_session_device_tests.rs @@ -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::>(); + 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); +} diff --git a/codex-rs/login/src/oauth_session_login.rs b/codex-rs/login/src/oauth_session_login.rs index 982ab246b7..2062b4aafb 100644 --- a/codex-rs/login/src/oauth_session_login.rs +++ b/codex-rs/login/src/oauth_session_login.rs @@ -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 { + 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, 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, + 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::::DeviceCode { + endpoint: device_authorization_url.to_owned(), + present, + }, + ) + .await + } + + async fn authorize_with_device_code( + &self, + cached: &mut OAuthSessionCache, + endpoint: &str, + previous_token: Option, + 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, + method: LoginMethod, ) -> 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::()) { + return io::Error::new(error.kind(), *device_error); + } let message = if error.kind() == io::ErrorKind::Interrupted { "Gateway sign-in was canceled" } else { diff --git a/codex-rs/login/src/oauth_session_storage_tests.rs b/codex-rs/login/src/oauth_session_storage_tests.rs index 88ec677d8c..f5ec5ede95 100644 --- a/codex-rs/login/src/oauth_session_storage_tests.rs +++ b/codex-rs/login/src/oauth_session_storage_tests.rs @@ -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) { +fn blocking_client( + configuration: OAuthSessionConfig, +) -> (OAuthSessionManager, tempfile::TempDir, Arc) { let home = tempfile::tempdir().unwrap(); let keyring = Arc::new(BlockingKeyring { inner: MockKeyringStore::default(), @@ -62,7 +65,7 @@ fn blocking_client() -> (OAuthSessionManager, tempfile::TempDir, Arc (OAuthSessionManager, tempfile::TempDir, Arc 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(), diff --git a/codex-rs/login/src/oauth_session_token.rs b/codex-rs/login/src/oauth_session_token.rs index fe05508626..4f632115d5 100644 --- a/codex-rs/login/src/oauth_session_token.rs +++ b/codex-rs/login/src/oauth_session_token.rs @@ -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, } +impl From 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, diff --git a/codex-rs/model-provider-info/src/gateway_oauth.rs b/codex-rs/model-provider-info/src/gateway_oauth.rs index 91bbbd18de..f42e740d9f 100644 --- a/codex-rs/model-provider-info/src/gateway_oauth.rs +++ b/codex-rs/model-provider-info/src/gateway_oauth.rs @@ -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, pub token_url: String, pub client_id: String, pub resource: Option, @@ -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( diff --git a/codex-rs/model-provider-info/src/gateway_oauth_tests.rs b/codex-rs/model-provider-info/src/gateway_oauth_tests.rs index da519be61d..3a6c00cd3c 100644 --- a/codex-rs/model-provider-info/src/gateway_oauth_tests.rs +++ b/codex-rs/model-provider-info/src/gateway_oauth_tests.rs @@ -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' }"); diff --git a/codex-rs/model-provider/src/shared_state.rs b/codex-rs/model-provider/src/shared_state.rs index 587df43729..24c2116b15 100644 --- a/codex-rs/model-provider/src/shared_state.rs +++ b/codex-rs/model-provider/src/shared_state.rs @@ -52,7 +52,7 @@ impl ModelProviderSharedState { ) -> std::io::Result> { 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(), diff --git a/codex-rs/model-provider/src/shared_state_test_support.rs b/codex-rs/model-provider/src/shared_state_test_support.rs index 23a926c2cf..2409f205ca 100644 --- a/codex-rs/model-provider/src/shared_state_test_support.rs +++ b/codex-rs/model-provider/src/shared_state_test_support.rs @@ -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, diff --git a/codex-rs/model-provider/src/shared_state_tests.rs b/codex-rs/model-provider/src/shared_state_tests.rs index 4180d4bde3..04b69780a0 100644 --- a/codex-rs/model-provider/src/shared_state_tests.rs +++ b/codex-rs/model-provider/src/shared_state_tests.rs @@ -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,