mirror of
https://github.com/openai/codex.git
synced 2026-11-07 20:30:33 +00:00
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:
Generated
+1
@@ -4067,6 +4067,7 @@ dependencies = [
|
||||
"http 1.4.0",
|
||||
"jsonwebtoken",
|
||||
"keyring",
|
||||
"oauth2",
|
||||
"once_cell",
|
||||
"opentelemetry_sdk",
|
||||
"os_info",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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' }");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user