[codex-rs] support v2 personal access tokens (#25731)

## Summary

- add v2 personal access token support for `codex login
--with-access-token` and `CODEX_ACCESS_TOKEN`
- classify opaque `at-` tokens separately from legacy Agent Identity
JWTs
- hydrate required ChatGPT account metadata through AuthAPI
`/v1/user-auth-credential/whoami`
- use PATs directly as bearer tokens while preserving existing ChatGPT
account surfaces
- expose PAT-backed auth as the explicit `personalAccessToken`
app-server auth mode

## Implementation

PAT auth is intentionally small and stateless. Loading a PAT performs
one AuthAPI metadata request, stores the hydrated metadata in the
in-memory auth object, and redacts the secret from debug output. Legacy
Agent Identity JWT handling remains unchanged. The shared access-token
classifier lives in a private neutral module because it dispatches
between both credential types.

PAT hydration fails closed when AuthAPI omits any required metadata,
including email. Hydrated metadata is intentionally not persisted:
startup performs a live `whoami` preflight so revoked tokens or changed
account metadata are not accepted from a stale cache.

## Workspace restriction scope

This change intentionally does **not** apply
`forced_chatgpt_workspace_id` to PAT authentication. The setting is a
client-side config guardrail, not an authorization boundary, and PAT
does not currently require workspace-ID parity. The PAT login and
`CODEX_ACCESS_TOKEN` paths therefore validate through AuthAPI without
threading workspace-restriction state through access-token loading.
Existing workspace checks for non-PAT auth remain on their established
paths.

## App-server compatibility

The public app-server `AuthMode` is shared across v1 and v2, and
PAT-backed auth reports `personalAccessToken` through both APIs.
Following human review, this intentionally removes the temporary v1
compatibility mapping that reported PATs as `chatgpt`; the deprecated v1
API is kept in parity with v2 rather than maintaining a separate closed
enum. Clients with exhaustive auth-mode handling in either API version
must add the new case and should generally treat it as ChatGPT-backed
unless they need PAT-specific behavior.

The v1 auth-status response still omits the raw PAT when `includeToken`
is requested because that response cannot carry the account metadata
needed to reuse the credential safely. Persisted PAT auth also omits the
new enum value so older Codex builds can deserialize `auth.json` and
infer PAT auth from the credential field after a rollback.

## Validation

Latest review-fix validation:

- `CARGO_INCREMENTAL=0 just test -p codex-login` (126 passed)
- `CARGO_INCREMENTAL=0 just test -p codex-cli` (263 passed)
- `CARGO_INCREMENTAL=0 just test -p codex-cli
stored_auth_validation_handles_personal_access_token`
- `CARGO_INCREMENTAL=0 just test -p codex-app-server-protocol` (226
passed)
- `CARGO_INCREMENTAL=0 just test -p codex-models-manager
refresh_available_models_uses_remote_only_catalog_for_chatgpt_auth`
- `CARGO_INCREMENTAL=0 just test -p codex-tui
existing_non_oauth_chatgpt_login_counts_as_signed_in`
- `CARGO_INCREMENTAL=0 just fix -p codex-login -p
codex-app-server-protocol -p codex-models-manager -p codex-tui -p
codex-cli`
- `just fmt`
- `git diff --check`

The broader `codex-tui` suite previously compiled and ran 2,834 tests.
Three unrelated environment-sensitive guardian/IDE-socket tests failed
after retries; the PAT-relevant TUI coverage passed.
This commit is contained in:
cooper-oai authored and GitHub committed 2026-06-05 17:36:18 -07:00
1 parent 61a913d9c8
commit df7818c7d1
42 files changed
+980 -97

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
const PERSONAL_ACCESS_TOKEN_PREFIX: &str = "at-";
pub(super) enum CodexAccessToken<'a> {
PersonalAccessToken(&'a str),
AgentIdentityJwt(&'a str),
}
pub(super) fn classify_codex_access_token(access_token: &str) -> CodexAccessToken<'_> {
if access_token.starts_with(PERSONAL_ACCESS_TOKEN_PREFIX) {
CodexAccessToken::PersonalAccessToken(access_token)
} else {
CodexAccessToken::AgentIdentityJwt(access_token)
}
}
#[cfg(test)]
#[path = "access_token_tests.rs"]
mod tests;
@@ -0,0 +1,13 @@
use super::*;
#[test]
fn classifies_personal_access_tokens_by_prefix() {
assert!(matches!(
classify_codex_access_token("at-example"),
CodexAccessToken::PersonalAccessToken("at-example")
));
assert!(matches!(
classify_codex_access_token("header.payload.signature"),
CodexAccessToken::AgentIdentityJwt("header.payload.signature")
));
}
+184
View File
@@ -19,6 +19,7 @@ use tempfile::tempdir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
@@ -126,6 +127,84 @@ async fn login_with_access_token_writes_only_token() {
server.verify().await;
}
#[tokio::test]
#[serial(codex_auth_env)]
async fn login_with_access_token_writes_only_personal_access_token() {
let dir = tempdir().unwrap();
let auth_path = dir.path().join("auth.json");
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/user-auth-credential/whoami"))
.and(header("authorization", "Bearer at-login-test"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(personal_access_token_whoami(WORKSPACE_ID_ALLOWED)),
)
.expect(1)
.mount(&server)
.await;
let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri());
super::login_with_access_token(
dir.path(),
"at-login-test",
AuthCredentialsStoreMode::File,
/*chatgpt_base_url*/ None,
)
.await
.expect("personal access token login should succeed");
let storage = FileAuthStorage::new(dir.path().to_path_buf());
let auth = storage
.try_read_auth_json(&auth_path)
.expect("auth.json should parse");
assert_eq!(
auth,
AuthDotJson {
auth_mode: None,
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: None,
personal_access_token: Some("at-login-test".to_string()),
}
);
assert_eq!(auth.resolved_mode(), AuthMode::PersonalAccessToken);
let persisted: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(auth_path).unwrap()).unwrap();
assert!(persisted.get("auth_mode").is_none());
server.verify().await;
}
#[tokio::test]
#[serial(codex_auth_env)]
async fn login_with_access_token_rejects_invalid_personal_access_token() {
let dir = tempdir().unwrap();
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/user-auth-credential/whoami"))
.respond_with(ResponseTemplate::new(403))
.expect(1)
.mount(&server)
.await;
let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri());
let err = super::login_with_access_token(
dir.path(),
"at-invalid-login",
AuthCredentialsStoreMode::File,
/*chatgpt_base_url*/ None,
)
.await
.expect_err("invalid personal access token should fail");
assert_eq!(err.kind(), std::io::ErrorKind::Other);
assert!(
!get_auth_file(dir.path()).exists(),
"invalid personal access token should not write auth.json"
);
server.verify().await;
}
#[tokio::test]
async fn login_with_access_token_rejects_invalid_jwt() {
let dir = tempdir().unwrap();
@@ -245,6 +324,7 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() {
}),
last_refresh: Some(last_refresh),
agent_identity: None,
personal_access_token: None,
},
auth_dot_json
);
@@ -286,6 +366,7 @@ fn logout_removes_auth_file() -> Result<(), std::io::Error> {
tokens: None,
last_refresh: None,
agent_identity: None,
personal_access_token: None,
};
super::save_auth(dir.path(), &auth_dot_json, AuthCredentialsStoreMode::File)?;
let auth_file = get_auth_file(dir.path());
@@ -762,6 +843,98 @@ async fn load_auth_reads_access_token_from_env() {
server.verify().await;
}
#[tokio::test]
#[serial(codex_auth_env)]
async fn load_auth_reads_personal_access_token_from_env() {
let codex_home = tempdir().unwrap();
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/user-auth-credential/whoami"))
.and(header("authorization", "Bearer at-env-test"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(personal_access_token_whoami(WORKSPACE_ID_ALLOWED)),
)
.expect(2)
.mount(&server)
.await;
let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri());
let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-env-test");
for auth_credentials_store_mode in [
AuthCredentialsStoreMode::File,
AuthCredentialsStoreMode::Ephemeral,
] {
let auth = super::load_auth(
codex_home.path(),
/*enable_codex_api_key_env*/ false,
auth_credentials_store_mode,
/*chatgpt_base_url*/ None,
)
.await
.expect("env auth should load")
.expect("env auth should be present");
assert_eq!(auth.api_auth_mode(), AuthMode::PersonalAccessToken);
assert_eq!(
auth.get_token()
.expect("personal access token should be exposed"),
"at-env-test"
);
assert_eq!(auth.get_account_id().as_deref(), Some(WORKSPACE_ID_ALLOWED));
assert_eq!(auth.get_chatgpt_user_id().as_deref(), Some("user-123"));
assert_eq!(
auth.get_account_email().as_deref(),
Some("user@example.com")
);
assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Business));
assert!(auth.is_fedramp_account());
}
assert!(
!get_auth_file(codex_home.path()).exists(),
"env auth should not write auth.json"
);
server.verify().await;
}
#[tokio::test]
#[serial(codex_auth_env)]
async fn personal_access_token_does_not_offer_unauthorized_recovery() {
let codex_home = tempdir().unwrap();
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/user-auth-credential/whoami"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(personal_access_token_whoami(WORKSPACE_ID_ALLOWED)),
)
.expect(1)
.mount(&server)
.await;
let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri());
let _access_token_guard =
EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-no-unauthorized-recovery");
let manager = Arc::new(
AuthManager::new(
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*chatgpt_base_url*/ None,
)
.await,
);
let recovery = manager.unauthorized_recovery();
assert!(!recovery.has_next());
assert_eq!(recovery.unavailable_reason(), "not_refreshable_auth");
manager
.refresh_token_from_authority()
.await
.expect("personal access tokens do not use OAuth refresh");
server.verify().await;
}
#[tokio::test]
#[serial(codex_auth_env)]
async fn load_auth_keeps_codex_api_key_env_precedence() {
@@ -938,6 +1111,7 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat
tokens: None,
last_refresh: None,
agent_identity: Some(agent_identity),
personal_access_token: None,
},
AuthCredentialsStoreMode::File,
)
@@ -1091,6 +1265,16 @@ fn test_jwks_body() -> serde_json::Value {
})
}
fn personal_access_token_whoami(account_id: &str) -> serde_json::Value {
json!({
"email": "user@example.com",
"chatgpt_user_id": "user-123",
"chatgpt_account_id": account_id,
"chatgpt_plan_type": "business",
"chatgpt_account_is_fedramp": true,
})
}
const TEST_AGENT_IDENTITY_RSA_PRIVATE_KEY_PEM: &[u8] = br#"-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDWpAXYypOsYAwO
bvBduMk/mxaoYDze0AZSzaSzLuIlcsl2EKDgC3AabhIWXh/qTGEJLOU3VB1e5mO9
+130 -30
View File
@@ -24,9 +24,12 @@ use codex_app_server_protocol::AuthMode as ApiAuthMode;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::ModelProviderAuthInfo;
use super::access_token::CodexAccessToken;
use super::access_token::classify_codex_access_token;
use super::external_bearer::BearerTokenRefresher;
use super::revoke::revoke_auth_tokens;
pub use crate::auth::agent_identity::AgentIdentityAuth;
pub use crate::auth::personal_access_token::PersonalAccessTokenAuth;
pub use crate::auth::storage::AgentIdentityAuthRecord;
pub use crate::auth::storage::AuthDotJson;
use crate::auth::storage::AuthStorageBackend;
@@ -53,11 +56,15 @@ pub enum CodexAuth {
Chatgpt(ChatgptAuth),
ChatgptAuthTokens(ChatgptAuthTokens),
AgentIdentity(AgentIdentityAuth),
PersonalAccessToken(PersonalAccessTokenAuth),
}
impl PartialEq for CodexAuth {
fn eq(&self, other: &Self) -> bool {
self.api_auth_mode() == other.api_auth_mode()
match (self, other) {
(Self::PersonalAccessToken(a), Self::PersonalAccessToken(b)) => a == b,
_ => self.api_auth_mode() == other.api_auth_mode(),
}
}
}
@@ -220,6 +227,14 @@ impl CodexAuth {
};
return Self::from_agent_identity_jwt(&agent_identity, chatgpt_base_url).await;
}
if auth_mode == ApiAuthMode::PersonalAccessToken {
let Some(personal_access_token) = auth_dot_json.personal_access_token.as_deref() else {
return Err(std::io::Error::other(
"personal access token auth is missing a personal access token.",
));
};
return Self::from_personal_access_token(personal_access_token).await;
}
let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode);
let state = ChatgptAuthState {
@@ -237,6 +252,9 @@ impl CodexAuth {
}
ApiAuthMode::ApiKey => unreachable!("api key mode is handled above"),
ApiAuthMode::AgentIdentity => unreachable!("agent identity mode is handled above"),
ApiAuthMode::PersonalAccessToken => {
unreachable!("personal access token mode is handled above")
}
}
}
@@ -266,11 +284,18 @@ impl CodexAuth {
Ok(Self::AgentIdentity(AgentIdentityAuth::load(record).await?))
}
pub async fn from_personal_access_token(access_token: &str) -> std::io::Result<Self> {
Ok(Self::PersonalAccessToken(
PersonalAccessTokenAuth::load(access_token).await?,
))
}
pub fn auth_mode(&self) -> AuthMode {
match self {
Self::ApiKey(_) => AuthMode::ApiKey,
Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => AuthMode::Chatgpt,
Self::AgentIdentity(_) => AuthMode::AgentIdentity,
Self::PersonalAccessToken(_) => AuthMode::PersonalAccessToken,
}
}
@@ -280,6 +305,7 @@ impl CodexAuth {
Self::Chatgpt(_) => ApiAuthMode::Chatgpt,
Self::ChatgptAuthTokens(_) => ApiAuthMode::ChatgptAuthTokens,
Self::AgentIdentity(_) => ApiAuthMode::AgentIdentity,
Self::PersonalAccessToken(_) => ApiAuthMode::PersonalAccessToken,
}
}
@@ -287,14 +313,21 @@ impl CodexAuth {
self.auth_mode() == AuthMode::ApiKey
}
pub fn is_personal_access_token_auth(&self) -> bool {
self.auth_mode() == AuthMode::PersonalAccessToken
}
pub fn is_chatgpt_auth(&self) -> bool {
matches!(self, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_))
self.api_auth_mode().has_chatgpt_account()
}
pub fn uses_codex_backend(&self) -> bool {
matches!(
self,
Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) | Self::AgentIdentity(_)
Self::Chatgpt(_)
| Self::ChatgptAuthTokens(_)
| Self::AgentIdentity(_)
| Self::PersonalAccessToken(_)
)
}
@@ -302,11 +335,18 @@ impl CodexAuth {
matches!(self, Self::ChatgptAuthTokens(_))
}
fn supports_unauthorized_recovery(&self) -> bool {
matches!(self, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_))
}
/// Returns `None` if `auth_mode() != AuthMode::ApiKey`.
pub fn api_key(&self) -> Option<&str> {
match self {
Self::ApiKey(auth) => Some(auth.api_key.as_str()),
Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) | Self::AgentIdentity(_) => None,
Self::Chatgpt(_)
| Self::ChatgptAuthTokens(_)
| Self::AgentIdentity(_)
| Self::PersonalAccessToken(_) => None,
}
}
@@ -334,6 +374,7 @@ impl CodexAuth {
Self::AgentIdentity(_) => Err(std::io::Error::other(
"agent identity auth does not expose a bearer token",
)),
Self::PersonalAccessToken(auth) => Ok(auth.access_token().to_string()),
}
}
@@ -341,6 +382,7 @@ impl CodexAuth {
pub fn get_account_id(&self) -> Option<String> {
match self {
Self::AgentIdentity(auth) => Some(auth.account_id().to_string()),
Self::PersonalAccessToken(auth) => Some(auth.account_id().to_string()),
_ => self.get_current_token_data().and_then(|t| t.account_id),
}
}
@@ -349,6 +391,7 @@ impl CodexAuth {
pub fn is_fedramp_account(&self) -> bool {
match self {
Self::AgentIdentity(auth) => auth.is_fedramp_account(),
Self::PersonalAccessToken(auth) => auth.is_fedramp_account(),
_ => self
.get_current_token_data()
.is_some_and(|t| t.id_token.is_fedramp_account()),
@@ -359,6 +402,7 @@ impl CodexAuth {
pub fn get_account_email(&self) -> Option<String> {
match self {
Self::AgentIdentity(auth) => Some(auth.email().to_string()),
Self::PersonalAccessToken(auth) => Some(auth.email().to_string()),
_ => self.get_current_token_data().and_then(|t| t.id_token.email),
}
}
@@ -367,6 +411,7 @@ impl CodexAuth {
pub fn get_chatgpt_user_id(&self) -> Option<String> {
match self {
Self::AgentIdentity(auth) => Some(auth.chatgpt_user_id().to_string()),
Self::PersonalAccessToken(auth) => Some(auth.chatgpt_user_id().to_string()),
_ => self
.get_current_token_data()
.and_then(|t| t.id_token.chatgpt_user_id),
@@ -380,6 +425,9 @@ impl CodexAuth {
if let Self::AgentIdentity(auth) = self {
return Some(auth.plan_type());
}
if let Self::PersonalAccessToken(auth) = self {
return Some(auth.plan_type());
}
self.get_current_token_data().map(|t| {
t.id_token
@@ -399,7 +447,7 @@ impl CodexAuth {
let state = match self {
Self::Chatgpt(auth) => &auth.state,
Self::ChatgptAuthTokens(auth) => &auth.state,
Self::ApiKey(_) | Self::AgentIdentity(_) => return None,
Self::ApiKey(_) | Self::AgentIdentity(_) | Self::PersonalAccessToken(_) => return None,
};
#[expect(clippy::unwrap_used)]
state.auth_dot_json.lock().unwrap().clone()
@@ -423,6 +471,7 @@ impl CodexAuth {
}),
last_refresh: Some(Utc::now()),
agent_identity: None,
personal_access_token: None,
};
let client = create_client();
@@ -539,6 +588,7 @@ pub fn login_with_api_key(
tokens: None,
last_refresh: None,
agent_identity: None,
personal_access_token: None,
};
save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode)
}
@@ -550,17 +600,35 @@ pub async fn login_with_access_token(
auth_credentials_store_mode: AuthCredentialsStoreMode,
chatgpt_base_url: Option<&str>,
) -> std::io::Result<()> {
let base_url = chatgpt_base_url
.unwrap_or(DEFAULT_CHATGPT_BACKEND_BASE_URL)
.trim_end_matches('/')
.to_string();
verified_agent_identity_record(access_token, &base_url).await?;
let auth_dot_json = AuthDotJson {
auth_mode: Some(ApiAuthMode::AgentIdentity),
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: Some(access_token.to_string()),
let auth_dot_json = match classify_codex_access_token(access_token) {
CodexAccessToken::PersonalAccessToken(access_token) => {
PersonalAccessTokenAuth::load(access_token).await?;
AuthDotJson {
// Infer PAT auth from the credential field so older Codex builds can still
// deserialize auth.json after a rollback.
auth_mode: None,
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: None,
personal_access_token: Some(access_token.to_string()),
}
}
CodexAccessToken::AgentIdentityJwt(jwt) => {
let base_url = chatgpt_base_url
.unwrap_or(DEFAULT_CHATGPT_BACKEND_BASE_URL)
.trim_end_matches('/')
.to_string();
verified_agent_identity_record(jwt, &base_url).await?;
AuthDotJson {
auth_mode: Some(ApiAuthMode::AgentIdentity),
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: Some(jwt.to_string()),
personal_access_token: None,
}
}
};
save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode)
}
@@ -633,10 +701,12 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<
(ForcedLoginMethod::Api, AuthMode::ApiKey) => None,
(ForcedLoginMethod::Chatgpt, AuthMode::Chatgpt)
| (ForcedLoginMethod::Chatgpt, AuthMode::ChatgptAuthTokens)
| (ForcedLoginMethod::Chatgpt, AuthMode::AgentIdentity) => None,
| (ForcedLoginMethod::Chatgpt, AuthMode::AgentIdentity)
| (ForcedLoginMethod::Chatgpt, AuthMode::PersonalAccessToken) => None,
(ForcedLoginMethod::Api, AuthMode::Chatgpt)
| (ForcedLoginMethod::Api, AuthMode::ChatgptAuthTokens)
| (ForcedLoginMethod::Api, AuthMode::AgentIdentity) => Some(
| (ForcedLoginMethod::Api, AuthMode::AgentIdentity)
| (ForcedLoginMethod::Api, AuthMode::PersonalAccessToken) => Some(
"API key login is required, but ChatGPT is currently being used. Logging out."
.to_string(),
),
@@ -657,7 +727,7 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<
if let Some(expected_account_ids) = config.forced_chatgpt_workspace_id.as_deref() {
let chatgpt_account_id = match &auth {
CodexAuth::ApiKey(_) => return Ok(()),
CodexAuth::ApiKey(_) | CodexAuth::PersonalAccessToken(_) => return Ok(()),
CodexAuth::AgentIdentity(_) => auth.get_account_id(),
CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) => {
let token_data = match auth.get_token_data() {
@@ -758,17 +828,26 @@ async fn load_auth(
return Ok(Some(auth));
}
if let Some(access_token) = read_codex_access_token_from_env() {
return match classify_codex_access_token(&access_token) {
CodexAccessToken::PersonalAccessToken(access_token) => {
CodexAuth::from_personal_access_token(access_token)
.await
.map(Some)
}
CodexAccessToken::AgentIdentityJwt(jwt) => {
CodexAuth::from_agent_identity_jwt(jwt, chatgpt_base_url)
.await
.map(Some)
}
};
}
// If the caller explicitly requested ephemeral auth, there is no persisted fallback.
if auth_credentials_store_mode == AuthCredentialsStoreMode::Ephemeral {
return Ok(None);
}
if let Some(agent_identity) = read_codex_access_token_from_env() {
return CodexAuth::from_agent_identity_jwt(&agent_identity, chatgpt_base_url)
.await
.map(Some);
}
// Fall back to the configured persistent store (file/keyring/auto) for managed auth.
let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode);
let auth_dot_json = match storage.load()? {
@@ -963,6 +1042,7 @@ impl AuthDotJson {
tokens: Some(tokens),
last_refresh: Some(Utc::now()),
agent_identity: None,
personal_access_token: None,
})
}
@@ -983,6 +1063,9 @@ impl AuthDotJson {
if let Some(mode) = self.auth_mode {
return mode;
}
if self.personal_access_token.is_some() {
return ApiAuthMode::PersonalAccessToken;
}
if self.openai_api_key.is_some() {
return ApiAuthMode::ApiKey;
}
@@ -1126,7 +1209,7 @@ impl UnauthorizedRecovery {
.manager
.auth_cached()
.as_ref()
.is_some_and(CodexAuth::is_chatgpt_auth)
.is_some_and(CodexAuth::supports_unauthorized_recovery)
{
return false;
}
@@ -1147,11 +1230,20 @@ impl UnauthorizedRecovery {
};
}
if self
.manager
.auth_cached()
.as_ref()
.is_some_and(CodexAuth::is_personal_access_token_auth)
{
return "not_refreshable_auth";
}
if !self
.manager
.auth_cached()
.as_ref()
.is_some_and(CodexAuth::is_chatgpt_auth)
.is_some_and(CodexAuth::supports_unauthorized_recovery)
{
return "not_chatgpt_auth";
}
@@ -1497,6 +1589,7 @@ impl AuthManager {
}
_ => false,
},
(ApiAuthMode::PersonalAccessToken, ApiAuthMode::PersonalAccessToken) => a == b,
_ => false,
},
_ => false,
@@ -1690,7 +1783,7 @@ impl AuthManager {
let auth_before_reload = self.auth_cached();
if auth_before_reload
.as_ref()
.is_some_and(CodexAuth::is_api_key_auth)
.is_some_and(|auth| auth.is_api_key_auth() || auth.is_personal_access_token_auth())
{
return Ok(());
}
@@ -1756,7 +1849,9 @@ impl AuthManager {
self.refresh_and_persist_chatgpt_token(&chatgpt_auth, token_data.refresh_token)
.await
}
CodexAuth::ApiKey(_) | CodexAuth::AgentIdentity(_) => Ok(()),
CodexAuth::ApiKey(_)
| CodexAuth::AgentIdentity(_)
| CodexAuth::PersonalAccessToken(_) => Ok(()),
};
if let Err(RefreshTokenError::Permanent(error)) = &result {
self.record_permanent_refresh_failure_if_unchanged(&attempted_auth, error);
@@ -1805,7 +1900,12 @@ impl AuthManager {
pub fn current_auth_uses_codex_backend(&self) -> bool {
matches!(
self.auth_mode(),
Some(AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens | AuthMode::AgentIdentity)
Some(
AuthMode::Chatgpt
| AuthMode::ChatgptAuthTokens
| AuthMode::AgentIdentity
| AuthMode::PersonalAccessToken
)
)
}
+2
View File
@@ -1,6 +1,8 @@
mod access_token;
mod agent_identity;
pub mod default_client;
pub mod error;
mod personal_access_token;
mod storage;
mod util;
@@ -0,0 +1,112 @@
use codex_client::CodexHttpClient;
use codex_protocol::account::PlanType as AccountPlanType;
use codex_protocol::auth::PlanType as InternalPlanType;
use serde::Deserialize;
use std::env;
use std::fmt;
use crate::default_client::create_client;
const PROD_AUTHAPI_BASE_URL: &str = "https://auth.openai.com/api/accounts";
const CODEX_AUTHAPI_BASE_URL_ENV_VAR: &str = "CODEX_AUTHAPI_BASE_URL";
const WHOAMI_PATH: &str = "/v1/user-auth-credential/whoami";
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
struct PersonalAccessTokenMetadata {
email: String,
chatgpt_user_id: String,
chatgpt_account_id: String,
chatgpt_plan_type: String,
chatgpt_account_is_fedramp: bool,
}
#[derive(Clone, PartialEq, Eq)]
pub struct PersonalAccessTokenAuth {
access_token: String,
metadata: PersonalAccessTokenMetadata,
}
impl fmt::Debug for PersonalAccessTokenAuth {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PersonalAccessTokenAuth")
.field("access_token", &"<redacted>")
.field("metadata", &self.metadata)
.finish()
}
}
impl PersonalAccessTokenAuth {
pub(super) async fn load(access_token: &str) -> std::io::Result<Self> {
let authapi_base_url = env::var(CODEX_AUTHAPI_BASE_URL_ENV_VAR)
.ok()
.map(|base_url| base_url.trim().trim_end_matches('/').to_string())
.filter(|base_url| !base_url.is_empty())
.unwrap_or_else(|| PROD_AUTHAPI_BASE_URL.to_string());
hydrate_personal_access_token(&create_client(), &authapi_base_url, access_token).await
}
pub fn access_token(&self) -> &str {
&self.access_token
}
pub fn account_id(&self) -> &str {
&self.metadata.chatgpt_account_id
}
pub fn chatgpt_user_id(&self) -> &str {
&self.metadata.chatgpt_user_id
}
pub fn email(&self) -> &str {
&self.metadata.email
}
pub fn plan_type(&self) -> AccountPlanType {
InternalPlanType::from_raw_value(&self.metadata.chatgpt_plan_type).into()
}
pub fn is_fedramp_account(&self) -> bool {
self.metadata.chatgpt_account_is_fedramp
}
}
async fn hydrate_personal_access_token(
client: &CodexHttpClient,
authapi_base_url: &str,
access_token: &str,
) -> std::io::Result<PersonalAccessTokenAuth> {
let endpoint = format!("{}{WHOAMI_PATH}", authapi_base_url.trim_end_matches('/'));
let response = client
.get(&endpoint)
.bearer_auth(access_token)
.send()
.await
.map_err(|err| {
std::io::Error::other(format!(
"failed to request personal access token metadata: {err}"
))
})?;
if !response.status().is_success() {
return Err(std::io::Error::other(format!(
"personal access token metadata request failed with status {}",
response.status()
)));
}
let metadata = response
.json::<PersonalAccessTokenMetadata>()
.await
.map_err(|err| {
std::io::Error::other(format!(
"failed to decode personal access token metadata: {err}"
))
})?;
Ok(PersonalAccessTokenAuth {
access_token: access_token.to_string(),
metadata,
})
}
#[cfg(test)]
#[path = "personal_access_token_tests.rs"]
mod tests;
@@ -0,0 +1,71 @@
use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
fn response(email: Option<&str>) -> serde_json::Value {
json!({
"email": email,
"chatgpt_user_id": "user-123",
"chatgpt_account_id": "account-123",
"chatgpt_plan_type": "enterprise",
"chatgpt_account_is_fedramp": true,
})
}
#[tokio::test]
async fn hydrate_sends_bearer_token_and_preserves_metadata() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(WHOAMI_PATH))
.and(header("authorization", "Bearer at-example"))
.respond_with(ResponseTemplate::new(200).set_body_json(response(Some("user@example.com"))))
.expect(1)
.mount(&server)
.await;
let auth = hydrate_personal_access_token(&create_client(), &server.uri(), "at-example")
.await
.expect("personal access token hydration should succeed");
assert_eq!(
auth,
PersonalAccessTokenAuth {
access_token: "at-example".to_string(),
metadata: PersonalAccessTokenMetadata {
email: "user@example.com".to_string(),
chatgpt_user_id: "user-123".to_string(),
chatgpt_account_id: "account-123".to_string(),
chatgpt_plan_type: "enterprise".to_string(),
chatgpt_account_is_fedramp: true,
},
}
);
server.verify().await;
}
#[tokio::test]
async fn hydrate_rejects_missing_email() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(WHOAMI_PATH))
.respond_with(ResponseTemplate::new(200).set_body_json(response(/*email*/ None)))
.expect(1)
.mount(&server)
.await;
let err = hydrate_personal_access_token(&create_client(), &server.uri(), "at-example")
.await
.expect_err("personal access token hydration should reject missing email");
assert!(
err.to_string()
.contains("failed to decode personal access token metadata")
);
server.verify().await;
}
+3
View File
@@ -45,6 +45,9 @@ pub struct AuthDotJson {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_identity: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub personal_access_token: Option<String>,
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
+28
View File
@@ -19,6 +19,7 @@ async fn file_storage_load_returns_auth_dot_json() -> anyhow::Result<()> {
tokens: None,
last_refresh: Some(Utc::now()),
agent_identity: None,
personal_access_token: None,
};
storage
@@ -40,6 +41,7 @@ async fn file_storage_save_persists_auth_dot_json() -> anyhow::Result<()> {
tokens: None,
last_refresh: Some(Utc::now()),
agent_identity: None,
personal_access_token: None,
};
let file = get_auth_file(codex_home.path());
@@ -73,6 +75,27 @@ async fn file_storage_round_trips_agent_identity_auth() -> anyhow::Result<()> {
tokens: None,
last_refresh: None,
agent_identity: Some(agent_identity),
personal_access_token: None,
};
storage.save(&auth_dot_json)?;
let loaded = storage.load()?;
assert_eq!(Some(auth_dot_json), loaded);
Ok(())
}
#[tokio::test]
async fn file_storage_round_trips_personal_access_token_auth() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
let auth_dot_json = AuthDotJson {
auth_mode: Some(AuthMode::PersonalAccessToken),
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: None,
personal_access_token: Some("at-example".to_string()),
};
storage.save(&auth_dot_json)?;
@@ -122,6 +145,7 @@ fn file_storage_delete_removes_auth_file() -> anyhow::Result<()> {
tokens: None,
last_refresh: None,
agent_identity: None,
personal_access_token: None,
};
let storage = create_auth_storage(dir.path().to_path_buf(), AuthCredentialsStoreMode::File);
storage.save(&auth_dot_json)?;
@@ -146,6 +170,7 @@ fn ephemeral_storage_save_load_delete_is_in_memory_only() -> anyhow::Result<()>
tokens: None,
last_refresh: Some(Utc::now()),
agent_identity: None,
personal_access_token: None,
};
storage.save(&auth_dot_json)?;
@@ -245,6 +270,7 @@ fn auth_with_prefix(prefix: &str) -> AuthDotJson {
}),
last_refresh: None,
agent_identity: None,
personal_access_token: None,
}
}
@@ -270,6 +296,7 @@ fn keyring_auth_storage_load_returns_deserialized_auth() -> anyhow::Result<()> {
tokens: None,
last_refresh: None,
agent_identity: None,
personal_access_token: None,
};
seed_keyring_with_auth(
&mock_keyring,
@@ -313,6 +340,7 @@ fn keyring_auth_storage_save_persists_and_removes_fallback_file() -> anyhow::Res
}),
last_refresh: Some(Utc::now()),
agent_identity: None,
personal_access_token: None,
};
storage.save(&auth)?;
+16
View File
@@ -822,6 +822,7 @@ pub(crate) async fn persist_tokens_async(
tokens: Some(tokens),
last_refresh: Some(Utc::now()),
agent_identity: None,
personal_access_token: None,
};
save_auth(&codex_home, &auth, auth_credentials_store_mode)?;
Ok::<_, io::Error>((previous_auth, auth))
@@ -940,6 +941,20 @@ pub(crate) fn ensure_workspace_allowed(
return Err("Login is restricted to a specific workspace, but the token did not include an chatgpt_account_id claim.".to_string());
};
ensure_workspace_account_allowed(Some(expected), actual)
}
/// Validates an already known ChatGPT account ID against an optional workspace restriction.
///
/// PAT login calls this directly because `/whoami` supplies the account ID without an ID token.
pub(crate) fn ensure_workspace_account_allowed(
expected: Option<&[String]>,
actual: &str,
) -> Result<(), String> {
let Some(expected) = expected else {
return Ok(());
};
if expected.iter().any(|workspace_id| workspace_id == actual) {
Ok(())
} else {
@@ -1311,6 +1326,7 @@ mod tests {
}),
last_refresh: None,
agent_identity: None,
personal_access_token: None,
}
}