Compare commits

...
5 Commits
Author SHA1 Message Date
ZyyandCodex 9eeec2c78c chore: bump version to 3.1.15
Co-authored-by: Codex <noreply@openai.com>
2026-08-07 15:15:00 +08:00
ZyyandCodex 90890be2b4 fix(openai): persist OAuth tokens before quota sync
Align OAuth token persistence and ID token metadata handling with Codex, and retry initial quota observation asynchronously.

Co-authored-by: Codex <noreply@openai.com>
2026-08-07 14:59:42 +08:00
ZyyandCodex 2e4bab17a8 chore: bump version to 3.1.14
Co-authored-by: Codex <noreply@openai.com>
2026-08-06 16:01:07 +08:00
ZyyandCodex b052e79a16 fix(openai): preserve imported token expiry
Co-authored-by: Codex <noreply@openai.com>
2026-08-06 15:49:42 +08:00
ZyyandCodex 9775451e83 fix(web): preserve button labels while loading
Co-authored-by: Codex <noreply@openai.com>
2026-08-06 15:27:00 +08:00
53 changed files with 923 additions and 5350 deletions
-35
View File
@@ -2125,11 +2125,9 @@ dependencies = [
"base64 0.22.1",
"getrandom 0.2.17",
"js-sys",
"pem",
"serde",
"serde_json",
"signature 2.2.0",
"simple_asn1",
"zeroize",
]
@@ -2280,16 +2278,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -2407,16 +2395,6 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64 0.22.1",
"serde_core",
]
[[package]]
name = "pem-rfc7468"
version = "1.0.0"
@@ -2610,7 +2588,6 @@ dependencies = [
"getrandom 0.4.3",
"hex",
"hyper-rustls",
"jsonwebtoken",
"reqwest",
"roxmltree",
"rustls",
@@ -3324,18 +3301,6 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simple_asn1"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
dependencies = [
"num-bigint",
"num-traits",
"thiserror",
"time",
]
[[package]]
name = "siphasher"
version = "1.0.3"
@@ -198,7 +198,7 @@ pub struct AccountRecord {
pub provider_kind: ProviderKind,
pub name: String,
pub email: Option<String>,
pub upstream_user_id: String,
pub upstream_user_id: Option<String>,
pub upstream_account_id: Option<String>,
pub plan_type: Option<String>,
pub authentication_kind: String,
@@ -146,7 +146,7 @@ pub struct PreparedCredentialCreate {
pub provider_kind: ProviderKind,
pub name: String,
pub email: Option<String>,
pub upstream_user_id: String,
pub upstream_user_id: Option<String>,
pub upstream_account_id: Option<String>,
pub plan_type: Option<String>,
pub authentication_kind: String,
@@ -84,25 +84,29 @@ impl DefaultOpenAiService {
}
}
async fn observe_initial_quotas(&self, account_ids: &[ProviderAccountId], request_id: &str) {
for account_id in account_ids {
if let Err(error) = self
.provider
.quota(ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: true,
rolling_usage: None,
})
.await
{
tracing::warn!(
request_id,
account_id = %account_id.as_str(),
quota_error = ?error.kind(),
"OpenAI initial quota observation failed"
);
fn observe_initial_quotas(&self, account_ids: &[ProviderAccountId], request_id: &str) {
let provider = Arc::clone(&self.provider);
let account_ids = account_ids.to_vec();
let request_id = request_id.to_owned();
tokio::spawn(async move {
for account_id in account_ids {
if let Err(error) = provider
.quota(ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: true,
rolling_usage: None,
})
.await
{
tracing::warn!(
request_id,
account_id = %account_id.as_str(),
quota_error = ?error.kind(),
"OpenAI initial quota observation failed"
);
}
}
}
});
}
}
@@ -155,8 +159,7 @@ impl OpenAiService for DefaultOpenAiService {
.await;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
// 导入完成后立即做一次观察;失败不回滚已经提交的 credential。
self.observe_initial_quotas(&result.credential_ids, &context.request_id)
.await;
self.observe_initial_quotas(&result.credential_ids, &context.request_id);
Ok(result)
}
@@ -208,8 +211,7 @@ impl OpenAiService for DefaultOpenAiService {
self.observe_initial_quotas(
std::slice::from_ref(&result.account_id),
&context.request_id,
)
.await;
);
Ok(result)
}
@@ -1373,7 +1373,7 @@ pub(super) fn account_record(kind: &str) -> AccountRecord {
provider_kind: ProviderKind::new(kind).expect("provider kind"),
name: "test account".to_owned(),
email: Some("test@example.invalid".to_owned()),
upstream_user_id: "upstream-user".to_owned(),
upstream_user_id: Some("upstream-user".to_owned()),
upstream_account_id: None,
plan_type: Some("test".to_owned()),
authentication_kind: "oauth".to_owned(),
@@ -1410,7 +1410,7 @@ fn prepared_create_with_id(
provider_kind,
name: name.to_owned(),
email: Some("prepared@example.invalid".to_owned()),
upstream_user_id: "prepared-user".to_owned(),
upstream_user_id: Some("prepared-user".to_owned()),
upstream_account_id: None,
plan_type: Some("test".to_owned()),
authentication_kind: "oauth".to_owned(),
@@ -125,6 +125,8 @@ async fn openai_import_should_prepare_before_atomic_store_commit() {
.await
.expect("import credential");
await_quota_requests(&provider, 1).await;
assert_eq!(
recorded(&events),
[
@@ -162,6 +164,8 @@ async fn openai_import_should_refresh_quota_for_every_imported_account() {
.await
.expect("import credentials");
await_quota_requests(&provider, 2).await;
assert_eq!(
result.credential_ids,
[
@@ -203,6 +207,8 @@ async fn openai_import_should_remain_successful_when_quota_refresh_fails() {
.await
.expect("committed import remains successful");
await_quota_requests(&provider, 1).await;
assert_eq!(
result.credential_ids,
[ProviderAccountId::new("acct_prepared").expect("account ID")]
@@ -236,6 +242,8 @@ async fn openai_authorization_create_should_observe_initial_quota() {
.await
.expect("complete authorization");
await_quota_requests(&provider, 1).await;
assert_eq!(
recorded(&events),
[
@@ -284,6 +292,8 @@ async fn openai_authorization_create_should_remain_successful_when_initial_quota
.await
.expect("quota observation must not fail authorization");
await_quota_requests(&provider, 1).await;
assert_eq!(result.account_id.as_str(), "acct_prepared");
assert_eq!(provider.quota_requests().len(), 1);
}
@@ -322,6 +332,8 @@ async fn openai_authorization_store_failure_should_release_claim_for_retry() {
.await
.expect("released claim must permit retry");
await_quota_requests(&provider, 1).await;
assert_eq!(
recorded(&events),
[
@@ -400,6 +412,8 @@ async fn openai_reauthorization_should_commit_after_credential_revision_advances
.await
.expect("complete reauthorization");
await_quota_requests(&provider, 1).await;
assert_eq!(result.credential_revision, Some(revision(3)));
assert_eq!(
@@ -426,6 +440,16 @@ async fn service(provider: Arc<FakeProviderAdmin>, store: Arc<FakeAccountStore>)
.await
}
async fn await_quota_requests(provider: &FakeProviderAdmin, expected: usize) {
for _ in 0..100 {
if provider.quota_requests().len() == expected {
return;
}
tokio::task::yield_now().await;
}
assert_eq!(provider.quota_requests().len(), expected);
}
fn mutation(account_id: &str) -> CredentialMutation {
CredentialMutation {
context: context("request-openai"),
@@ -1382,7 +1382,7 @@ fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> AccountView {
resource_ref: account.id,
email: account.email,
account_id: account.upstream_account_id,
user_id: Some(account.upstream_user_id),
user_id: account.upstream_user_id,
label: None,
plan_type: account.plan_type,
authentication_kind: account.authentication_kind,
@@ -251,7 +251,7 @@ pub struct ProviderAccount {
provider: ProviderKind,
name: String,
email: Option<String>,
upstream_user_id: String,
upstream_user_id: Option<String>,
upstream_account_id: Option<String>,
plan_type: Option<String>,
authentication_kind: String,
@@ -270,7 +270,7 @@ impl ProviderAccount {
id: ProviderAccountId,
provider: ProviderKind,
name: String,
upstream_user_id: String,
upstream_user_id: Option<String>,
authentication_kind: String,
revision: CredentialRevision,
access_token_expires_at: Option<SystemTime>,
@@ -313,7 +313,11 @@ impl ProviderAccount {
availability: AccountAvailability,
) -> Self {
self.enabled = enabled;
self.availability = availability;
self.availability = if self.upstream_user_id.is_some() {
availability
} else {
AccountAvailability::Unknown
};
self
}
@@ -354,8 +358,8 @@ impl ProviderAccount {
}
#[must_use]
pub fn upstream_user_id(&self) -> &str {
&self.upstream_user_id
pub fn upstream_user_id(&self) -> Option<&str> {
self.upstream_user_id.as_deref()
}
#[must_use]
@@ -409,7 +413,8 @@ impl ProviderAccount {
| AccountAvailability::Banned
| AccountAvailability::Invalid => false,
};
self.enabled
self.upstream_user_id.is_some()
&& self.enabled
&& available
&& self
.access_token_expires_at
@@ -19,7 +19,7 @@ fn account(id: &str) -> ProviderAccount {
ProviderAccountId::new(id).expect("valid account"),
ProviderKind::new("openai").expect("valid provider"),
id.to_owned(),
format!("user-{id}"),
Some(format!("user-{id}")),
"oauth".to_owned(),
CredentialRevision::new(1).expect("valid revision"),
Some(SystemTime::now() + Duration::from_secs(3600)),
@@ -160,6 +160,28 @@ fn disabled_account_should_not_be_schedulable() {
assert!(!account.is_schedulable(SystemTime::now()));
}
#[test]
fn account_without_upstream_identity_should_stay_unknown_and_not_schedulable() {
let account = ProviderAccount::new(
ProviderAccountId::new("acct_pending_identity").expect("valid account"),
ProviderKind::new("openai").expect("valid provider"),
"pending identity".to_owned(),
None,
"oauth".to_owned(),
CredentialRevision::new(1).expect("valid revision"),
Some(SystemTime::now() + Duration::from_secs(3600)),
)
.with_runtime_state(true, AccountAvailability::Ready);
assert_eq!(
(
account.availability(),
account.is_schedulable(SystemTime::now())
),
(AccountAvailability::Unknown, false)
);
}
#[test]
fn smart_selector_should_prefer_lower_inflight_count() {
let candidates = vec![
@@ -19,7 +19,7 @@ impl ProviderAccountStore for PgProviderAccountRepository {
provider_kind: account.account.provider().as_str().to_owned(),
name: account.account.name().to_owned(),
email: account.account.email().map(str::to_owned),
upstream_user_id: account.account.upstream_user_id().to_owned(),
upstream_user_id: account.account.upstream_user_id().map(str::to_owned),
upstream_account_id: account.account.upstream_account_id().map(str::to_owned),
plan_type: account.account.plan_type().map(str::to_owned),
authentication_kind: account.account.authentication_kind().to_owned(),
@@ -47,7 +47,7 @@ pub(crate) fn account_matches_admin_query(
Some(account.name.as_str()),
account.email.as_deref(),
account.upstream_account_id.as_deref(),
Some(account.upstream_user_id.as_str()),
account.upstream_user_id.as_deref(),
]
.into_iter()
.flatten()
@@ -110,6 +110,11 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
async fn insert_provider_account(&self, account: NewProviderAccount) -> StoreResult<()> {
account.validate()?;
let availability = if account.upstream_user_id.is_some() {
account.availability
} else {
AccountAvailability::Unknown
};
sqlx::query(
"insert into provider_accounts (
id, provider_kind, name, email, upstream_user_id,
@@ -135,7 +140,7 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
.bind(account.access_token_expires_at)
.bind(account.next_refresh_at)
.bind(account.enabled)
.bind(account.availability.as_str())
.bind(availability.as_str())
.bind(account.availability_observed_at)
.execute(&self.pool)
.await
@@ -209,13 +214,16 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
update.validate()?;
let result = sqlx::query(
"update provider_accounts
set availability = case when enabled then $3 else availability end,
set availability = case
when enabled and upstream_user_id is not null then $3
else availability
end,
availability_observed_at = case
when enabled then $4
when enabled and upstream_user_id is not null then $4
else availability_observed_at
end,
last_error_message = case
when enabled then
when enabled and upstream_user_id is not null then
case when $3 = 'ready' then null else coalesce($5, last_error_message) end
else last_error_message
end,
@@ -466,6 +474,11 @@ pub(crate) async fn upsert_provider_account_in_transaction(
account: &NewProviderAccount,
) -> StoreResult<String> {
account.validate()?;
let availability = if account.upstream_user_id.is_some() {
account.availability
} else {
AccountAvailability::Unknown
};
let imported_id = sqlx::query_scalar::<_, String>(
"insert into provider_accounts (
id, provider_kind, name, email, upstream_user_id,
@@ -512,7 +525,7 @@ pub(crate) async fn upsert_provider_account_in_transaction(
.bind(account.access_token_expires_at)
.bind(account.next_refresh_at)
.bind(account.enabled)
.bind(account.availability.as_str())
.bind(availability.as_str())
.bind(account.availability_observed_at)
.fetch_optional(&mut **transaction)
.await
@@ -563,8 +576,9 @@ pub(crate) async fn rotate_provider_account_in_transaction(
upstream_account_id = case when $11::boolean then $13::text else upstream_account_id end,
availability = case
when not enabled then availability
when availability <> 'quota_exhausted' then 'ready'
else availability
when availability = 'quota_exhausted' then availability
when coalesce($12::text, upstream_user_id) is not null then 'ready'
else 'unknown'
end,
availability_observed_at = case
when not enabled then availability_observed_at
@@ -76,7 +76,7 @@ pub struct ProviderAccountSummary {
pub provider_kind: String,
pub name: String,
pub email: Option<String>,
pub upstream_user_id: String,
pub upstream_user_id: Option<String>,
pub upstream_account_id: Option<String>,
pub plan_type: Option<String>,
pub authentication_kind: String,
@@ -123,7 +123,7 @@ pub struct NewProviderAccount {
pub provider_kind: String,
pub name: String,
pub email: Option<String>,
pub upstream_user_id: String,
pub upstream_user_id: Option<String>,
pub upstream_account_id: Option<String>,
pub plan_type: Option<String>,
pub authentication_kind: String,
@@ -163,7 +163,9 @@ impl NewProviderAccount {
require_nonempty(ENTITY, "id", &self.id)?;
require_nonempty(ENTITY, "provider_kind", &self.provider_kind)?;
require_nonempty(ENTITY, "name", &self.name)?;
require_nonempty(ENTITY, "upstream_user_id", &self.upstream_user_id)?;
if let Some(upstream_user_id) = &self.upstream_user_id {
require_nonempty(ENTITY, "upstream_user_id", upstream_user_id)?;
}
require_nonempty(ENTITY, "authentication_kind", &self.authentication_kind)?;
if !self.has_refresh_token && self.next_refresh_at.is_some() {
return Err(invalid("next_refresh_at requires a refresh token"));
@@ -156,7 +156,12 @@ async fn connect_and_migrate_should_apply_all_migrations_once_and_reopen_cleanly
.expect("drop migration test database");
admin.close().await;
assert_eq!((first_table_count, migration_count), (10, 1));
assert_eq!(first_table_count, 10);
assert_eq!(
migration_count,
i64::try_from(TEST_MIGRATOR.iter().count())
.expect("migration count fits PostgreSQL bigint")
);
assert_eq!(response_id_types, ["bytea", "bytea"]);
assert!(!raw_response_id_index_exists);
}
@@ -49,6 +49,35 @@ fn postgres_provider_account_adapter_implements_core_port() {
assert_terminal_admin_port::<PgAdminAccountStore>();
}
#[tokio::test]
async fn provider_account_should_allow_missing_upstream_user_id() {
let Some(database) = TestDatabase::create("provider_account_missing_upstream_identity").await
else {
return;
};
let repository = PgProviderAccountRepository::new(database.pool.clone());
let mut pending = account("acct_pending_identity", "unused");
pending.upstream_user_id = None;
pending.email = None;
pending.availability = AccountAvailability::Ready;
repository
.insert_provider_account(pending)
.await
.expect("insert account without upstream identity");
let stored = repository
.load_provider_account("acct_pending_identity")
.await
.expect("load account")
.expect("stored account");
assert_eq!(
(stored.summary.upstream_user_id, stored.summary.availability,),
(None, AccountAvailability::Unknown)
);
database.close().await;
}
#[tokio::test]
async fn core_quota_batch_reads_only_observed_accounts_in_one_contract_call() {
let Some(database) = TestDatabase::create("provider_account_quota_batch").await else {
@@ -882,7 +911,7 @@ async fn core_refresh_cas_updates_profile_and_credential_under_one_revision() {
provider_kind: "xai".to_owned(),
name: "before refresh".to_owned(),
email: Some("before@example.invalid".to_owned()),
upstream_user_id: "upstream-core-refresh".to_owned(),
upstream_user_id: Some("upstream-core-refresh".to_owned()),
upstream_account_id: None,
plan_type: Some("free".to_owned()),
authentication_kind: "oauth".to_owned(),
@@ -1405,7 +1434,7 @@ fn account(id: &str, upstream_user_id: &str) -> NewProviderAccount {
provider_kind: "openai".to_owned(),
name: id.to_owned(),
email: Some(format!("{id}@example.invalid")),
upstream_user_id: upstream_user_id.to_owned(),
upstream_user_id: Some(upstream_user_id.to_owned()),
upstream_account_id: None,
plan_type: Some("pro".to_owned()),
authentication_kind: "oauth".to_owned(),
@@ -27,7 +27,6 @@ hex = "0.4.3"
# reqwest 0.12.28 + ring)。rustls 0.23.37 起 ClientHello 扩展顺序变化,JA3
# 会偏离官方指纹;升级前必须重新抓握手对比扩展顺序。
hyper-rustls = "=0.27.7"
jsonwebtoken = { version = "=11.0.0", default-features = false, features = ["aws_lc_rs", "use_pem"] }
# 与官方 Codex Desktop 一致:reqwest 不启用压缩特性,上游请求不带 accept-encoding。
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "cookies", "rustls-tls-native-roots", "http2"] }
roxmltree = "0.21.1"
+9 -90
View File
@@ -33,18 +33,14 @@ use gateway_core::error::StoreErrorKind;
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload};
use gateway_core::provider_ports::{
NewOAuthPendingFlow, OAuthPendingBinding, OAuthPendingClaimOutcome, OAuthPendingConsumeOutcome,
OAuthPendingFlowPort, OAuthPendingPutOutcome, OAuthPendingReleaseOutcome,
ProviderRuntimePolicyPort, ProviderStoreError, ProviderStoreErrorKind,
OAuthPendingFlowPort, OAuthPendingPutOutcome, OAuthPendingReleaseOutcome, ProviderStoreError,
ProviderStoreErrorKind,
};
use gateway_core::routing::{ProviderKind, UpstreamModelId};
use secrecy::{ExposeSecret as _, SecretString};
use serde::Deserialize;
use serde_json::{Map, Number, Value};
use crate::credential::{
CodexAccountIdentityVerifier, CodexCredentialCodec, CodexIdentityExpectation, CodexOAuthSecret,
oauth_owner_ref,
};
use crate::credential::{
CodexAccountQuotaSnapshot, CodexCredentialAdmin, CodexCredentialAdminError,
CodexCredentialAdminService, CodexCredentialCatalogError, CodexCredentialCatalogService,
@@ -52,9 +48,9 @@ use crate::credential::{
CodexOAuthPendingClaimOutcome, CodexOAuthPendingStore, CodexOAuthPendingStoreError,
CodexPendingAuthorization, CodexQuotaWindow, CodexQuotaWindowKind, CodexQuotaWindowRole,
CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential, ExportManagedCodexCredential,
RotateManagedCodexCredential, StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
refresh_time,
StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
};
use crate::credential::{CodexCredentialCodec, CodexOAuthSecret, oauth_owner_ref};
use crate::transport::CodexWebSocketPool;
use crate::transport::openai_billing_breakdown;
use crate::transport::profile::{
@@ -63,8 +59,6 @@ use crate::transport::profile::{
const PROVIDER_NAME: &str = "openai";
const PENDING_DOCUMENT_SCHEMA_VERSION: u64 = 3;
const MAX_ACCESS_TOKEN_BYTES: usize = 16 * 1024;
const MAX_REFRESH_TOKEN_BYTES: usize = 64 * 1024;
/// OpenAI 对终态 Admin port 的唯一实现。
pub(crate) struct OpenAiAdminProvider {
@@ -72,22 +66,18 @@ pub(crate) struct OpenAiAdminProvider {
profile: CodexWireProfileState,
accounts: Arc<dyn ProviderAccountStore>,
credentials: Arc<CodexCredentialAdminService>,
verifier: Arc<dyn CodexAccountIdentityVerifier>,
oauth: Arc<dyn CodexOAuthAdmin>,
quota: Arc<CodexCredentialQuotaService>,
catalog: Arc<CodexCredentialCatalogService>,
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
websocket_pool: Arc<CodexWebSocketPool>,
desktop_release: CodexDesktopReleaseStatus,
}
pub(crate) struct OpenAiAdminServices {
pub(crate) credentials: Arc<CodexCredentialAdminService>,
pub(crate) verifier: Arc<dyn CodexAccountIdentityVerifier>,
pub(crate) oauth: Arc<dyn CodexOAuthAdmin>,
pub(crate) quota: Arc<CodexCredentialQuotaService>,
pub(crate) catalog: Arc<CodexCredentialCatalogService>,
pub(crate) runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
}
impl OpenAiAdminProvider {
@@ -105,11 +95,9 @@ impl OpenAiAdminProvider {
profile,
accounts,
credentials: services.credentials,
verifier: services.verifier,
oauth: services.oauth,
quota: services.quota,
catalog: services.catalog,
runtime_policy: services.runtime_policy,
websocket_pool,
desktop_release,
}
@@ -381,49 +369,9 @@ impl ProviderAdmin for OpenAiAdminProvider {
if !account_matches_record(&current.account, &command.account) {
return Err(provider_admin_error(ProviderAdminErrorKind::Conflict));
}
let runtime = CodexCredentialCodec::decode(&current.credential)
.map_err(|_| provider_admin_error(ProviderAdminErrorKind::Invalid))?;
let account_id = current
.account
.upstream_account_id()
.ok_or_else(|| provider_admin_error(ProviderAdminErrorKind::Invalid))?;
let principal = runtime
.principal
.ok_or_else(|| provider_admin_error(ProviderAdminErrorKind::Invalid))?;
let expectation = CodexIdentityExpectation::current(
principal.oauth_subject,
principal.poid,
account_id.to_owned(),
current.account.upstream_user_id().to_owned(),
runtime.installation_id,
)
.map_err(|_| provider_admin_error(ProviderAdminErrorKind::Invalid))?;
let secret = rotation_secret(command.provider_material)?;
let verified_account = self
.verifier
.verify(&secret, &expectation)
.await
.and_then(crate::credential::CodexIdentityVerification::into_complete)
.map_err(|_| provider_admin_error(ProviderAdminErrorKind::Invalid))?;
let policy = self
.runtime_policy
.load_refresh_policy()
.await
.map_err(map_provider_store_error)?;
let next_refresh_at = refresh_time(
policy,
current.account.id(),
verified_account.access_token_expires_at,
secret.refresh_token.is_some(),
)
.map_err(map_credential_admin_error)?;
let prepared = CodexCredentialAdmin
.prepare_rotation(RotateManagedCodexCredential {
current,
secret,
verified_account,
next_refresh_at,
})
.prepare_refreshed_oauth_rotation(current, secret, None, None)
.map_err(map_credential_admin_error)?;
prepared_rotation(prepared, command.account.provider_kind)
}
@@ -577,7 +525,7 @@ fn prepared_create(
provider_kind: account.provider().clone(),
name: account.name().to_owned(),
email: account.email().map(str::to_owned),
upstream_user_id: account.upstream_user_id().to_owned(),
upstream_user_id: account.upstream_user_id().map(str::to_owned),
upstream_account_id: account.upstream_account_id().map(str::to_owned),
plan_type: account.plan_type().map(str::to_owned),
authentication_kind: account.authentication_kind().to_owned(),
@@ -641,15 +589,6 @@ fn rotation_secret(document: ProviderDocument) -> Result<CodexOAuthSecret, Provi
let document: RotationDocument =
serde_json::from_value(Value::Object(document.into_provider_data().into_inner()))
.map_err(|_| provider_admin_error(ProviderAdminErrorKind::Invalid))?;
if document.access_token.is_empty()
|| document.access_token.len() > MAX_ACCESS_TOKEN_BYTES
|| document
.refresh_token
.as_deref()
.is_some_and(|token| token.is_empty() || token.len() > MAX_REFRESH_TOKEN_BYTES)
{
return Err(provider_admin_error(ProviderAdminErrorKind::Invalid));
}
Ok(CodexOAuthSecret {
access_token: SecretString::from(document.access_token),
refresh_token: document.refresh_token.map(SecretString::from),
@@ -737,7 +676,7 @@ fn dashboard_desktop_release(
fn account_matches_record(account: &ProviderAccount, record: &AccountRecord) -> bool {
account.id().as_str() == record.id
&& account.provider() == &record.provider_kind
&& account.upstream_user_id() == record.upstream_user_id
&& account.upstream_user_id() == record.upstream_user_id.as_deref()
&& account.upstream_account_id() == record.upstream_account_id.as_deref()
&& account.authentication_kind() == record.authentication_kind
}
@@ -1353,14 +1292,6 @@ fn map_pending_store_error(error: ProviderStoreError) -> CodexOAuthPendingStoreE
}
}
fn map_provider_store_error(error: ProviderStoreError) -> ProviderAdminError {
provider_admin_error(match error.kind() {
ProviderStoreErrorKind::InvalidData => ProviderAdminErrorKind::Invalid,
ProviderStoreErrorKind::Conflict => ProviderAdminErrorKind::Conflict,
ProviderStoreErrorKind::Unavailable => ProviderAdminErrorKind::Unavailable,
})
}
fn map_store_error(error: gateway_core::error::StoreError) -> ProviderAdminError {
provider_admin_error(match error.kind() {
StoreErrorKind::Conflict => ProviderAdminErrorKind::Conflict,
@@ -1377,38 +1308,28 @@ fn map_credential_admin_error(error: CodexCredentialAdminError) -> ProviderAdmin
provider_admin_error(match error {
Error::InvalidInput
| Error::InvalidCredential
| Error::IdentityMismatch
| Error::MissingRefreshToken
| Error::RefreshRejected
| Error::AccountBanned
| Error::AccountProfileRejected
| Error::IdentityRejected => ProviderAdminErrorKind::Invalid,
| Error::AccountBanned => ProviderAdminErrorKind::Invalid,
Error::NotFound => ProviderAdminErrorKind::NotFound,
Error::RefreshLeaseUnavailable | Error::RefreshAmbiguous => {
ProviderAdminErrorKind::Conflict
}
Error::RefreshUnavailable
| Error::AccountProfileUnavailable
| Error::IdentityUnavailable => ProviderAdminErrorKind::Unavailable,
Error::RefreshUnavailable => ProviderAdminErrorKind::Unavailable,
})
}
const fn credential_admin_error_code(error: CodexCredentialAdminError) -> &'static str {
match error {
CodexCredentialAdminError::InvalidInput => "invalid_input",
CodexCredentialAdminError::IdentityMismatch => "identity_mismatch",
CodexCredentialAdminError::InvalidCredential => "invalid_credential",
CodexCredentialAdminError::NotFound => "not_found",
CodexCredentialAdminError::MissingRefreshToken => "missing_refresh_token",
CodexCredentialAdminError::RefreshLeaseUnavailable => "refresh_lease_unavailable",
CodexCredentialAdminError::RefreshRejected => "refresh_rejected",
CodexCredentialAdminError::AccountBanned => "account_banned",
CodexCredentialAdminError::AccountProfileRejected => "account_profile_rejected",
CodexCredentialAdminError::AccountProfileUnavailable => "account_profile_unavailable",
CodexCredentialAdminError::RefreshUnavailable => "refresh_unavailable",
CodexCredentialAdminError::RefreshAmbiguous => "refresh_ambiguous",
CodexCredentialAdminError::IdentityRejected => "identity_rejected",
CodexCredentialAdminError::IdentityUnavailable => "identity_unavailable",
}
}
@@ -1437,7 +1358,6 @@ fn map_oauth_error(error: CodexOAuthAdminError) -> ProviderAdminError {
Error::InvalidInput
| Error::CallbackRejected
| Error::TokenRejected
| Error::IdentityRejected
| Error::Credential => ProviderAdminErrorKind::Invalid,
Error::NotFound | Error::FlowExpired => ProviderAdminErrorKind::NotFound,
Error::Conflict | Error::Ambiguous => ProviderAdminErrorKind::Conflict,
@@ -1455,7 +1375,6 @@ const fn oauth_error_code(error: &CodexOAuthAdminError) -> &'static str {
CodexOAuthAdminError::FlowExpired => "flow_expired",
CodexOAuthAdminError::CallbackRejected => "callback_rejected",
CodexOAuthAdminError::TokenRejected => "token_rejected",
CodexOAuthAdminError::IdentityRejected => "identity_rejected",
CodexOAuthAdminError::UpstreamUnavailable => "upstream_unavailable",
CodexOAuthAdminError::Ambiguous => "ambiguous",
CodexOAuthAdminError::StorageUnavailable => "storage_unavailable",
@@ -25,16 +25,13 @@ use serde::Serialize;
use serde_json::Value;
use thiserror::Error;
use super::identity::{
CodexAuthenticatedAccountSource, CodexIdentityExpectation, CodexIdentityVerificationError,
};
use super::recovery_log::{CodexOAuthRecoveryOperation, record_oauth_recovery};
use super::security::CodexCredentialCodec;
use super::token_client::{RefreshFailure, TokenRefresher};
use super::types::{
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile,
CodexAgentIdentityAuthMode, CodexAgentIdentityCredentialData, CodexCredentialData,
CodexOAuthSecret,
CodexCredentialPrincipal, CodexOAuthMetadata, CodexOAuthSecret,
};
const PROVIDER_NAME: &str = "openai";
@@ -53,6 +50,35 @@ pub struct ImportCodexOAuthCredential {
pub enabled: bool,
}
/// OAuth credential 的最小创建输入。
///
/// metadata 来自 ID token 的本地 payload 解码,或导入文档的展示字段;不包含
/// 签名、issuer、audience、nonce 或 access/refresh token 验证。
pub(crate) struct UnresolvedCodexOAuthCredential {
pub(crate) account_id: String,
pub(crate) name: String,
pub(crate) secret: CodexOAuthSecret,
pub(crate) metadata: CodexOAuthMetadata,
pub(crate) access_token_expires_at: Option<DateTime<Utc>>,
pub(crate) next_refresh_at: Option<DateTime<Utc>>,
pub(crate) enabled: bool,
}
impl fmt::Debug for UnresolvedCodexOAuthCredential {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("UnresolvedCodexOAuthCredential")
.field("account_id", &self.account_id)
.field("name", &self.name)
.field("secret", &"<redacted>")
.field("metadata", &self.metadata)
.field("access_token_expires_at", &self.access_token_expires_at)
.field("next_refresh_at", &self.next_refresh_at)
.field("enabled", &self.enabled)
.finish()
}
}
impl std::fmt::Debug for ImportCodexOAuthCredential {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
@@ -109,6 +135,7 @@ enum ParsedCodexAuthentication {
OAuth {
access_token: Option<String>,
refresh_token: Option<String>,
id_token: Option<String>,
},
AgentIdentity {
runtime_id: String,
@@ -123,6 +150,7 @@ impl fmt::Debug for ParsedCodexAuthentication {
Self::OAuth {
access_token,
refresh_token,
id_token,
} => formatter
.debug_struct("OAuth")
.field("access_token", &access_token.as_ref().map(|_| "<redacted>"))
@@ -130,6 +158,7 @@ impl fmt::Debug for ParsedCodexAuthentication {
"refresh_token",
&refresh_token.as_ref().map(|_| "<redacted>"),
)
.field("id_token", &id_token.as_ref().map(|_| "<redacted>"))
.finish(),
Self::AgentIdentity {
runtime_id: _,
@@ -350,8 +379,6 @@ impl fmt::Debug for PreparedCodexCredentialRotationGuard {
pub enum CodexCredentialAdminError {
#[error("Codex account input is invalid")]
InvalidInput,
#[error("Codex account identity does not match the existing account")]
IdentityMismatch,
#[error("Codex credential JSON is invalid")]
InvalidCredential,
#[error("Codex account was not found")]
@@ -368,14 +395,6 @@ pub enum CodexCredentialAdminError {
RefreshUnavailable,
#[error("Codex refresh send state is ambiguous")]
RefreshAmbiguous,
#[error("Codex account profile request was rejected")]
AccountProfileRejected,
#[error("Codex account profile request is unavailable")]
AccountProfileUnavailable,
#[error("Codex refreshed identity was rejected")]
IdentityRejected,
#[error("Codex identity verification is unavailable")]
IdentityUnavailable,
}
/// 无状态的 Codex Admin command preparer。
@@ -405,7 +424,7 @@ impl CodexCredentialAdmin {
account_id,
provider,
input.name,
upstream_user_id,
Some(upstream_user_id),
CODEX_AUTHENTICATION_KIND_OAUTH.to_owned(),
revision,
access_token_expires_at,
@@ -426,6 +445,59 @@ impl CodexCredentialAdmin {
})
}
/// 为已取得 OAuth access token、但资料尚未补全的账号创建最小记录。
///
/// ID token payload 只用于本地字段投影;创建路径绝不发起 usage/profile 请求。
pub(crate) fn prepare_unresolved_oauth(
&self,
input: UnresolvedCodexOAuthCredential,
) -> Result<NewProviderAccount, CodexCredentialAdminError> {
let account_id = ProviderAccountId::new(input.account_id)
.map_err(|_| CodexCredentialAdminError::InvalidInput)?;
let provider = ProviderKind::new(PROVIDER_NAME)
.map_err(|_| CodexCredentialAdminError::InvalidInput)?;
if input.name.trim().is_empty() {
return Err(CodexCredentialAdminError::InvalidInput);
}
let revision =
CredentialRevision::new(1).map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let CodexOAuthMetadata {
email,
chatgpt_plan_type: plan_type,
chatgpt_user_id: upstream_user_id,
chatgpt_account_id: upstream_account_id,
} = input.metadata;
let has_upstream_user_id = upstream_user_id.is_some();
let credential = CodexCredentialCodec::encode_unresolved(&input.secret, Vec::new())
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let account = ProviderAccount::new(
account_id,
provider,
input.name,
upstream_user_id,
CODEX_AUTHENTICATION_KIND_OAUTH.to_owned(),
revision,
optional_time(input.access_token_expires_at),
)
.with_profile(email, upstream_account_id, plan_type)
.with_runtime_state(
input.enabled,
if has_upstream_user_id {
AccountAvailability::Ready
} else {
AccountAvailability::Unknown
},
)
.with_refresh_schedule(
input.secret.refresh_token.is_some(),
optional_time(input.next_refresh_at),
);
Ok(NewProviderAccount {
account,
credential,
})
}
/// 严格输出可被 CPR 导入逻辑直接读取的 canonical 文档。
pub fn format_cpr_export(
&self,
@@ -450,7 +522,7 @@ impl CodexCredentialAdmin {
id: account.id().as_str().to_owned(),
email: account.email().map(str::to_owned),
account_id: account.upstream_account_id().map(str::to_owned),
user_id: Some(account.upstream_user_id().to_owned()),
user_id: account.upstream_user_id().map(str::to_owned),
label: Some(account.name().to_owned()),
plan_type: account.plan_type().map(str::to_owned),
status: cpr_status(&account),
@@ -513,19 +585,19 @@ impl CodexCredentialAdmin {
&self,
current: LoadedCredential,
secret: CodexOAuthSecret,
access_token_expires_at: DateTime<Utc>,
access_token_expires_at: Option<DateTime<Utc>>,
next_refresh_at: Option<DateTime<Utc>>,
) -> Result<PreparedCodexCredentialRotation, CodexCredentialAdminError> {
if current.account.provider().as_str() != PROVIDER_NAME
|| current.account.authentication_kind() != CODEX_AUTHENTICATION_KIND_OAUTH
{
return Err(CodexCredentialAdminError::IdentityMismatch);
return Err(CodexCredentialAdminError::InvalidCredential);
}
let mut data = CodexCredentialCodec::decode_complete(&current.credential)
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let oauth = data
.oauth_mut()
.ok_or(CodexCredentialAdminError::IdentityMismatch)?;
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
oauth.access_token = secret.access_token.expose_secret().to_owned();
oauth.refresh_token = secret
.refresh_token
@@ -549,7 +621,7 @@ impl CodexCredentialAdmin {
profile.clone(),
credential,
secret.refresh_token.is_some(),
Some(SystemTime::from(access_token_expires_at)),
optional_time(access_token_expires_at),
optional_time(next_refresh_at),
)
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
@@ -566,24 +638,16 @@ impl CodexCredentialAdmin {
input: RotateManagedCodexCredential,
replace_identity: bool,
) -> Result<PreparedCodexCredentialRotation, CodexCredentialAdminError> {
let access_token_expires_at =
required_time(input.verified_account.access_token_expires_at)?;
let access_token_expires_at = input.verified_account.access_token_expires_at;
let mut data = CodexCredentialCodec::decode_complete(&input.current.credential)
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let oauth = data
.oauth_mut()
.ok_or(CodexCredentialAdminError::IdentityMismatch)?;
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
if input.current.account.provider().as_str() != PROVIDER_NAME
|| input.current.account.authentication_kind() != CODEX_AUTHENTICATION_KIND_OAUTH
|| (!replace_identity
&& (input.current.account.upstream_account_id()
!= Some(input.verified_account.chatgpt_account_id.as_str())
|| input.current.account.upstream_user_id()
!= input.verified_account.chatgpt_user_id
|| oauth.principal.oauth_subject != input.verified_account.oauth_subject
|| oauth.principal.poid != input.verified_account.poid))
{
return Err(CodexCredentialAdminError::IdentityMismatch);
return Err(CodexCredentialAdminError::InvalidCredential);
}
let replacement_identity = replace_identity.then(|| {
ProviderAccountIdentity::new(
@@ -592,14 +656,10 @@ impl CodexCredentialAdmin {
)
});
if replace_identity {
oauth
.principal
.oauth_subject
.clone_from(&input.verified_account.oauth_subject);
oauth
.principal
.poid
.clone_from(&input.verified_account.poid);
oauth.principal = Some(CodexCredentialPrincipal {
oauth_subject: input.verified_account.oauth_subject.clone(),
poid: input.verified_account.poid.clone(),
});
}
oauth.access_token = input.secret.access_token.expose_secret().to_owned();
oauth.refresh_token = input
@@ -626,7 +686,7 @@ impl CodexCredentialAdmin {
profile.clone(),
credential,
input.secret.refresh_token.is_some(),
Some(access_token_expires_at),
optional_time(access_token_expires_at),
optional_time(input.next_refresh_at),
)
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
@@ -642,7 +702,6 @@ impl CodexCredentialAdmin {
/// 有状态的 Codex 手工刷新边界;消费调用方刚读取的当前 credential 并准备 CAS。
pub struct CodexCredentialAdminService {
refresher: Arc<dyn TokenRefresher>,
account_source: Arc<dyn CodexAuthenticatedAccountSource>,
leases: Arc<dyn ProviderLeasePort>,
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
}
@@ -652,7 +711,6 @@ impl fmt::Debug for CodexCredentialAdminService {
formatter
.debug_struct("CodexCredentialAdminService")
.field("refresher", &"TokenRefresher")
.field("account_source", &"CodexAuthenticatedAccountSource")
.field("leases", &"ProviderLeasePort")
.field("runtime_policy", &"ProviderRuntimePolicyPort")
.finish()
@@ -662,13 +720,11 @@ impl fmt::Debug for CodexCredentialAdminService {
impl CodexCredentialAdminService {
pub fn new(
refresher: Arc<dyn TokenRefresher>,
account_source: Arc<dyn CodexAuthenticatedAccountSource>,
leases: Arc<dyn ProviderLeasePort>,
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
) -> Self {
Self {
refresher,
account_source,
leases,
runtime_policy,
}
@@ -743,17 +799,14 @@ impl CodexCredentialAdminService {
Some(account_id.as_str()),
&secret,
);
if secret.access_token.expose_secret().trim().is_empty() || tokens.expires_in.is_zero() {
return Err(CodexCredentialAdminError::InvalidCredential);
}
let access_token_expires_at = SystemTime::now()
.checked_add(tokens.expires_in)
.map(DateTime::<Utc>::from)
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
let access_token_expires_at = tokens
.expires_in
.and_then(|expires_in| SystemTime::now().checked_add(expires_in))
.map(DateTime::<Utc>::from);
let next_refresh_at = refresh_time(
policy,
&account_id,
Some(access_token_expires_at),
access_token_expires_at,
secret.refresh_token.is_some(),
)?;
let mut prepared = CodexCredentialAdmin.prepare_refreshed_oauth_rotation(
@@ -769,7 +822,10 @@ impl CodexCredentialAdminService {
Ok(prepared)
}
/// 用 access token 补全账号事实,并归一到唯一 `NewProviderAccount` 写入路径。
/// 归一导入 OAuth/agent-identity 凭据到唯一 `NewProviderAccount` 写入路径。
///
/// OAuth 导入只要取得 access token(直接提供或 RT exchange)即创建 Unknown
/// 账号;资格资料由后续 worker 异步补齐。
pub async fn prepare_import_document(
&self,
payload: Value,
@@ -788,12 +844,12 @@ impl CodexCredentialAdminService {
let mut account_ids = BTreeSet::new();
let mut upstream_identities = BTreeSet::new();
let mut accounts = Vec::with_capacity(candidates.len());
let mut policy = None;
for candidate in candidates {
let prepared = match &candidate.authentication {
ParsedCodexAuthentication::OAuth {
access_token,
refresh_token,
id_token,
} => {
let account_id = format!("acct_{}", uuid::Uuid::now_v7().simple());
if !account_ids.insert(account_id.clone()) {
@@ -806,53 +862,43 @@ impl CodexCredentialAdminService {
&typed_account_id,
access_token.clone(),
refresh_token.clone(),
id_token.clone(),
)
.await?;
let mut verified_account = complete_oauth_account_profile(
self.account_source.as_ref(),
&secret,
access_token_expires_at,
)
.await
.map_err(map_account_profile_error)?;
apply_import_metadata(&mut verified_account, &candidate);
if !upstream_identities.insert((
verified_account.chatgpt_user_id.clone(),
verified_account.chatgpt_account_id.clone(),
)) {
return Err(CodexCredentialAdminError::InvalidInput);
}
let refresh_policy = if let Some(policy) = policy {
policy
} else {
let loaded = self
.runtime_policy
.load_refresh_policy()
.await
.map_err(|_| CodexCredentialAdminError::RefreshUnavailable)?;
policy = Some(loaded);
loaded
};
let next_refresh_at = refresh_time(
refresh_policy,
&typed_account_id,
verified_account.access_token_expires_at,
secret.refresh_token.is_some(),
)?;
CodexCredentialAdmin.prepare_import(ImportCodexOAuthCredential {
account_id,
name: candidate
.name
.clone()
.or_else(|| candidate.email.clone())
.or_else(|| verified_account.email.clone())
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| "Codex OAuth".to_owned()),
secret,
verified_account,
next_refresh_at,
enabled: true,
})?
// 导入材料按管理员输入原样保存;ID token 仅尽力补展示字段,
// 解析失败不阻塞 AT/RT 入库。
let mut metadata = secret
.id_token
.as_ref()
.and_then(|token| {
super::types::parse_id_token_metadata(token.expose_secret()).ok()
})
.unwrap_or_default();
apply_import_metadata(&mut metadata, &candidate);
let next_refresh_at = self
.best_effort_import_refresh_time(
&typed_account_id,
access_token_expires_at,
secret.refresh_token.is_some(),
)
.await;
CodexCredentialAdmin.prepare_unresolved_oauth(
UnresolvedCodexOAuthCredential {
account_id,
name: candidate
.name
.clone()
.or_else(|| candidate.email.clone())
.or_else(|| metadata.email.clone())
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| "Codex OAuth".to_owned()),
secret,
metadata,
access_token_expires_at,
next_refresh_at,
enabled: true,
},
)?
}
ParsedCodexAuthentication::AgentIdentity { .. } => {
let account_id = candidate
@@ -867,14 +913,17 @@ impl CodexCredentialAdminService {
import_runtime_state(candidate.status.as_deref())?;
let mut account =
self.prepare_agent_identity_import(&candidate, account_id, enabled)?;
if !upstream_identities.insert((
account.account.upstream_user_id().to_owned(),
account
.account
.upstream_account_id()
.ok_or(CodexCredentialAdminError::InvalidInput)?
.to_owned(),
)) {
let upstream_user_id = account
.account
.upstream_user_id()
.ok_or(CodexCredentialAdminError::InvalidInput)?
.to_owned();
let upstream_account_id = account
.account
.upstream_account_id()
.ok_or(CodexCredentialAdminError::InvalidInput)?
.to_owned();
if !upstream_identities.insert((upstream_user_id, upstream_account_id)) {
return Err(CodexCredentialAdminError::InvalidInput);
}
account.account = account.account.with_runtime_state(enabled, availability);
@@ -891,20 +940,14 @@ impl CodexCredentialAdminService {
account_id: &ProviderAccountId,
access_token: Option<String>,
refresh_token: Option<String>,
id_token: Option<String>,
) -> Result<(CodexOAuthSecret, Option<DateTime<Utc>>), CodexCredentialAdminError> {
let access_token = access_token
.as_deref()
.map(normalize_bearer)
.filter(|token| !token.is_empty());
let refresh_token = refresh_token
.as_deref()
.map(normalize_bearer)
.filter(|token| !token.is_empty());
let id_token = id_token.map(SecretString::from);
if let Some(access_token) = access_token {
let secret = CodexOAuthSecret {
access_token: SecretString::from(access_token),
refresh_token: refresh_token.map(SecretString::from),
id_token: None,
id_token,
};
Self::record_recovery_log(
CodexOAuthRecoveryOperation::ImportDirect,
@@ -926,21 +969,35 @@ impl CodexCredentialAdminService {
.refresh_token
.map(SecretString::from)
.or_else(|| Some(SecretString::from(refresh_token))),
id_token: None,
id_token,
};
Self::record_recovery_log(
CodexOAuthRecoveryOperation::ImportRefreshToken,
Some(account_id.as_str()),
&secret,
);
if secret.access_token.expose_secret().trim().is_empty() || tokens.expires_in.is_zero() {
return Err(CodexCredentialAdminError::InvalidCredential);
}
let access_token_expires_at = SystemTime::now()
.checked_add(tokens.expires_in)
.map(DateTime::<Utc>::from)
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
Ok((secret, Some(access_token_expires_at)))
let access_token_expires_at = tokens
.expires_in
.and_then(|expires_in| SystemTime::now().checked_add(expires_in))
.map(DateTime::<Utc>::from);
Ok((secret, access_token_expires_at))
}
async fn best_effort_import_refresh_time(
&self,
account_id: &ProviderAccountId,
access_token_expires_at: Option<DateTime<Utc>>,
has_refresh_token: bool,
) -> Option<DateTime<Utc>> {
let policy = self.runtime_policy.load_refresh_policy().await.ok()?;
refresh_time(
policy,
account_id,
access_token_expires_at,
has_refresh_token,
)
.ok()
.flatten()
}
fn record_recovery_log(
@@ -1008,7 +1065,7 @@ impl CodexCredentialAdminService {
account_id,
provider,
name,
upstream_user_id,
Some(upstream_user_id),
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY.to_owned(),
revision,
None,
@@ -1027,14 +1084,6 @@ impl CodexCredentialAdminService {
}
}
fn required_time(
value: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<SystemTime, CodexCredentialAdminError> {
value
.map(SystemTime::from)
.ok_or(CodexCredentialAdminError::InvalidCredential)
}
fn optional_time(value: Option<chrono::DateTime<chrono::Utc>>) -> Option<SystemTime> {
value.map(SystemTime::from)
}
@@ -1048,10 +1097,10 @@ pub(crate) fn refresh_time(
if !has_refresh_token {
return Ok(None);
}
let Some(expires_at) = access_token_expires_at.map(SystemTime::from) else {
return Ok(None);
};
let observed_at = SystemTime::now();
let expires_at = access_token_expires_at
.map(SystemTime::from)
.unwrap_or(observed_at);
policy
.next_attempt_at(account_id, expires_at, observed_at)
.map(DateTime::<Utc>::from)
@@ -1195,6 +1244,7 @@ fn parse_oauth_import_tokens(
) -> Result<ParsedCodexAuthentication, CodexCredentialAdminError> {
let mut access_token = None;
let mut refresh_token = None;
let mut id_token = None;
let mut pending = vec![value];
while let Some(current) = pending.pop() {
match current {
@@ -1203,18 +1253,14 @@ fn parse_oauth_import_tokens(
let token = match key.as_str() {
"access_token" | "accessToken" | "at" | "token" => &mut access_token,
"refresh_token" | "refreshToken" | "rt" => &mut refresh_token,
"id_token" | "idToken" => &mut id_token,
_ => {
pending.push(value);
continue;
}
};
let value = value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
if token.replace(value.to_owned()).is_some() {
return Err(CodexCredentialAdminError::InvalidInput);
if let Some(value) = value.as_str() {
*token = Some(value.to_owned());
}
}
}
@@ -1228,6 +1274,7 @@ fn parse_oauth_import_tokens(
Ok(ParsedCodexAuthentication::OAuth {
access_token,
refresh_token,
id_token,
})
}
@@ -1271,63 +1318,21 @@ fn first_string(value: &Value, keys: &[&str]) -> Option<String> {
.map(str::to_owned)
}
fn normalize_bearer(value: &str) -> String {
let value = value.trim();
value
.strip_prefix("Bearer ")
.or_else(|| value.strip_prefix("bearer "))
.unwrap_or(value)
.trim()
.to_owned()
}
pub(crate) async fn complete_oauth_account_profile(
account_source: &dyn CodexAuthenticatedAccountSource,
secret: &CodexOAuthSecret,
access_token_expires_at: Option<DateTime<Utc>>,
) -> Result<CodexAccountProfile, CodexIdentityVerificationError> {
let account = account_source
.fetch(secret, &CodexIdentityExpectation::default())
.await?;
Ok(CodexAccountProfile {
email: account.email,
oauth_subject: account.chatgpt_user_id.clone(),
poid: None,
chatgpt_account_id: account.chatgpt_account_id,
chatgpt_user_id: account.chatgpt_user_id,
plan_type: account.plan_type,
access_token_expires_at,
})
}
fn apply_import_metadata(profile: &mut CodexAccountProfile, candidate: &ParsedCodexImportAccount) {
if profile.email.is_none() {
profile.email = candidate
fn apply_import_metadata(metadata: &mut CodexOAuthMetadata, candidate: &ParsedCodexImportAccount) {
if metadata.email.is_none() {
metadata.email = candidate
.email
.as_deref()
.and_then(|value| normalize_import_text(value, MAX_IMPORT_EMAIL_BYTES));
}
if profile.plan_type.is_none() {
profile.plan_type = candidate
if metadata.chatgpt_plan_type.is_none() {
metadata.chatgpt_plan_type = candidate
.plan_type
.as_deref()
.and_then(|value| normalize_import_text(value, MAX_IMPORT_PLAN_BYTES));
}
}
const fn map_account_profile_error(
error: CodexIdentityVerificationError,
) -> CodexCredentialAdminError {
match error {
CodexIdentityVerificationError::Rejected => {
CodexCredentialAdminError::AccountProfileRejected
}
CodexIdentityVerificationError::Unavailable => {
CodexCredentialAdminError::AccountProfileUnavailable
}
}
}
fn normalize_import_text(value: &str, max_bytes: usize) -> Option<String> {
let value = value.trim();
(!value.is_empty() && value.len() <= max_bytes && !value.chars().any(char::is_control))
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,6 @@ mod affinity;
mod agent_identity;
mod catalog;
mod cookie;
mod identity;
mod oauth;
mod quota;
mod recovery_log;
@@ -16,7 +15,6 @@ mod selector;
pub mod token_client;
mod types;
pub(crate) use admin::refresh_time;
pub(crate) use affinity::{
derive_codex_cyber_policy_session_key, derive_codex_session_affinity_key,
};
@@ -39,13 +37,6 @@ pub use catalog::{
CodexCredentialCatalogSnapshot, CodexPlanCatalog,
};
pub use cookie::{CodexCookiePolicy, CookiePolicyError};
pub use identity::{
CodexAccountIdentityService, CodexAccountIdentityVerifier, CodexAuthenticatedAccount,
CodexAuthenticatedAccountSource, CodexIdentityExpectation, CodexIdentityVerification,
CodexIdentityVerificationError, CodexJwksSource, CodexJwtIdentityVerifier, CodexSignedIdentity,
CodexSignedIdentityVerifier, OFFICIAL_OPENAI_API_AUDIENCE, OFFICIAL_OPENAI_ISSUER,
OFFICIAL_OPENAI_JWKS_URI, ReqwestCodexAuthenticatedAccountSource, ReqwestOpenAiJwksSource,
};
pub use oauth::{
CodexOAuthAdmin, CodexOAuthAdminError, CodexOAuthAdminService, CodexOAuthAuthorizationStarted,
CodexOAuthPendingClaimOutcome, CodexOAuthPendingStore, CodexOAuthPendingStoreError,
@@ -27,16 +27,17 @@ use url::Url;
use uuid::Uuid;
use super::admin::{
CodexCredentialAdmin, CodexCredentialAdminError, ImportCodexOAuthCredential,
PreparedCodexCredentialRotation, complete_oauth_account_profile, refresh_time,
CodexCredentialAdmin, CodexCredentialAdminError, PreparedCodexCredentialRotation,
UnresolvedCodexOAuthCredential, refresh_time,
};
use super::identity::{CodexAuthenticatedAccountSource, CodexIdentityVerificationError};
use super::recovery_log::{CodexOAuthRecoveryOperation, record_oauth_recovery};
use super::refresh::initial_oauth_refresh_at;
use super::security::CodexCredentialCodec;
use super::token_client::{
AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant,
OFFICIAL_CODEX_OAUTH_CLIENT_ID, OFFICIAL_CODEX_REDIRECT_URI,
};
use super::types::parse_id_token_metadata;
const AUTHORIZATION_ENDPOINT: &str = "https://auth.openai.com/oauth/authorize";
const AUTHORIZATION_SCOPE: &str = "openid profile email offline_access";
@@ -320,8 +321,6 @@ pub enum CodexOAuthAdminError {
CallbackRejected,
#[error("Codex OAuth token exchange was rejected")]
TokenRejected,
#[error("Codex OAuth identity verification was rejected")]
IdentityRejected,
#[error("Codex OAuth upstream is unavailable")]
UpstreamUnavailable,
#[error("Codex OAuth exchange send state is ambiguous")]
@@ -348,7 +347,6 @@ pub trait CodexOAuthAdmin: Send + Sync {
pub struct CodexOAuthAdminService {
pending: Arc<dyn CodexOAuthPendingStore>,
exchanger: Arc<dyn AuthorizationCodeExchanger>,
account_source: Arc<dyn CodexAuthenticatedAccountSource>,
store: Arc<dyn ProviderAccountStore>,
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
credentials: CodexCredentialAdmin,
@@ -400,7 +398,6 @@ impl CodexOAuthAdminService {
pub fn new(
pending: Arc<dyn CodexOAuthPendingStore>,
exchanger: Arc<dyn AuthorizationCodeExchanger>,
account_source: Arc<dyn CodexAuthenticatedAccountSource>,
store: Arc<dyn ProviderAccountStore>,
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
credentials: CodexCredentialAdmin,
@@ -408,7 +405,6 @@ impl CodexOAuthAdminService {
Self {
pending,
exchanger,
account_source,
store,
runtime_policy,
credentials,
@@ -510,24 +506,20 @@ impl CodexOAuthAdminService {
.exchange_pending(&pending, callback_url, fallback_refresh_token)
.await?;
let mutation = pending.mutation.clone();
let access_token_expires_at = SystemTime::now()
.checked_add(expires_in)
.map(DateTime::<Utc>::from)
.ok_or(CodexOAuthAdminError::Credential)?;
let access_token_expires_at = expires_in
.and_then(|expires_in| SystemTime::now().checked_add(expires_in))
.map(DateTime::<Utc>::from);
let metadata = parse_id_token_metadata(id_token.expose_secret())
.map_err(|_| CodexOAuthAdminError::TokenRejected)?;
secret.id_token = Some(id_token);
let policy = self
.runtime_policy
.load_refresh_policy()
.await
.map_err(|_| CodexOAuthAdminError::StorageUnavailable)?;
let credential = if let Some(current) = current {
let next_refresh_at = refresh_time(
policy,
current.account.id(),
Some(access_token_expires_at),
secret.refresh_token.is_some(),
)
.map_err(map_admin_error)?;
let next_refresh_at = self
.best_effort_refresh_time(
current.account.id(),
access_token_expires_at,
secret.refresh_token.is_some(),
)
.await;
CompletedCodexOAuthCredential::Reauthorize(
self.credentials
.prepare_refreshed_oauth_rotation(
@@ -540,29 +532,20 @@ impl CodexOAuthAdminService {
)
} else {
let account_id = format!("acct_{}", Uuid::now_v7().simple());
let typed_account_id = ProviderAccountId::new(account_id.clone())
.map_err(|_| CodexOAuthAdminError::Credential)?;
let profile = complete_oauth_account_profile(
self.account_source.as_ref(),
&secret,
Some(access_token_expires_at),
)
.await
.map_err(map_account_profile_error)?;
let next_refresh_at = refresh_time(
policy,
&typed_account_id,
Some(access_token_expires_at),
secret.refresh_token.is_some(),
)
.map_err(map_admin_error)?;
let next_refresh_at = self
.best_effort_initial_refresh_time(
access_token_expires_at,
secret.refresh_token.is_some(),
)
.await;
CompletedCodexOAuthCredential::Create(
self.credentials
.prepare_import(ImportCodexOAuthCredential {
.prepare_unresolved_oauth(UnresolvedCodexOAuthCredential {
account_id,
name: pending.name,
secret,
verified_account: profile,
metadata,
access_token_expires_at,
next_refresh_at,
enabled: true,
})
@@ -571,6 +554,38 @@ impl CodexOAuthAdminService {
};
Ok((mutation, credential))
}
async fn best_effort_refresh_time(
&self,
account_id: &ProviderAccountId,
access_token_expires_at: Option<DateTime<Utc>>,
has_refresh_token: bool,
) -> Option<DateTime<Utc>> {
let policy = self.runtime_policy.load_refresh_policy().await.ok()?;
refresh_time(
policy,
account_id,
access_token_expires_at,
has_refresh_token,
)
.ok()
.flatten()
}
async fn best_effort_initial_refresh_time(
&self,
access_token_expires_at: Option<DateTime<Utc>>,
has_refresh_token: bool,
) -> Option<DateTime<Utc>> {
let policy = self.runtime_policy.load_refresh_policy().await.ok()?;
initial_oauth_refresh_at(
policy,
access_token_expires_at.map(SystemTime::from),
SystemTime::now(),
has_refresh_token,
)
.map(DateTime::<Utc>::from)
}
}
impl CodexOAuthAdminService {
@@ -670,8 +685,14 @@ impl CodexOAuthAdminService {
pending: &CodexPendingAuthorization,
callback_url: &str,
fallback_refresh_token: Option<SecretString>,
) -> Result<(super::types::CodexOAuthSecret, SecretString, Duration), CodexOAuthAdminError>
{
) -> Result<
(
super::types::CodexOAuthSecret,
SecretString,
Option<Duration>,
),
CodexOAuthAdminError,
> {
let (code, callback_state) = callback_parts(callback_url)?;
if !constant_time_equal(
pending.state.expose_secret().as_bytes(),
@@ -848,13 +869,6 @@ fn map_exchange_error(error: AuthorizationCodeExchangeError) -> CodexOAuthAdminE
}
}
const fn map_account_profile_error(error: CodexIdentityVerificationError) -> CodexOAuthAdminError {
match error {
CodexIdentityVerificationError::Rejected => CodexOAuthAdminError::TokenRejected,
CodexIdentityVerificationError::Unavailable => CodexOAuthAdminError::UpstreamUnavailable,
}
}
fn map_admin_error(_: CodexCredentialAdminError) -> CodexOAuthAdminError {
CodexOAuthAdminError::Credential
}
@@ -46,6 +46,8 @@ const DEFAULT_RATE_LIMIT_COOLDOWN: Duration = Duration::from_secs(60);
pub(crate) const QUOTA_SCHEDULING_TTL: Duration = Duration::from_secs(10 * 60);
const QUOTA_HYDRATION_FAILURE_TTL: Duration = Duration::from_secs(5);
const QUOTA_PERIODIC_REFRESH_MIN_INTERVAL: Duration = Duration::from_secs(30 * 60);
/// 首次 OAuth 异步观察失败时,由既有 quota worker 兜底重试的单轮上限。
const INITIAL_QUOTA_SYNC_BATCH: usize = 100;
// 5xx 上游拒绝的短退避重试预算;指数退避 1s/2s,吞掉瞬时抖动。
const QUOTA_FETCH_5XX_MAX_RETRIES: u32 = 2;
const QUOTA_FETCH_5XX_BASE_DELAY: Duration = Duration::from_secs(1);
@@ -507,7 +509,17 @@ impl CodexCredentialQuotaService {
let accounts = self.repository.list_for_provider().await?;
let mut summary = CodexQuotaSyncSummary::default();
let now = SystemTime::now();
let accounts = self.scheduling.reserve_periodic_refreshes(accounts, now);
let initial = self.initial_quota_sync_accounts(&accounts, now).await?;
let periodic = self.scheduling.reserve_periodic_refreshes(accounts, now);
let accounts = initial
.into_iter()
.chain(periodic)
.fold(BTreeMap::new(), |mut unique, account| {
unique.insert(account.id().clone(), account);
unique
})
.into_values()
.collect::<Vec<_>>();
if accounts.is_empty() {
return Ok(summary);
}
@@ -582,6 +594,31 @@ impl CodexCredentialQuotaService {
Ok(summary)
}
/// `quota_observed_at` 为空代表首次异步观察尚未成功;不另建同步状态表。
async fn initial_quota_sync_accounts(
&self,
accounts: &[ProviderAccount],
now: SystemTime,
) -> Result<Vec<ProviderAccount>, CodexCredentialQuotaError> {
let account_ids = accounts
.iter()
.map(|account| account.id().clone())
.collect::<Vec<_>>();
let observed = self.store.get_quotas(&account_ids).await?;
let observed_ids = observed
.into_iter()
.filter_map(|observation| observation.observed_at.map(|_| observation.account_id))
.collect::<BTreeSet<_>>();
Ok(accounts
.iter()
.filter(|account| {
!observed_ids.contains(account.id()) && eligible_initial_quota_sync(account, now)
})
.take(INITIAL_QUOTA_SYNC_BATCH)
.cloned()
.collect())
}
/// 解析并 revision-fenced 落库单账号的 Provider quota JSON。
async fn apply_fetched_quota(
&self,
@@ -1120,6 +1157,15 @@ fn eligible_periodic_quota_refresh(account: &ProviderAccount, now: SystemTime) -
account.enabled() && access_token_is_current(account, now)
}
fn eligible_initial_quota_sync(account: &ProviderAccount, now: SystemTime) -> bool {
// 首次观察只兜底刚入库、尚无 quota 快照的账号。已耗尽等运行时状态必须由
// periodic 路径处理,才能保留同一账号的最小复核间隔。
matches!(
account.availability(),
AccountAvailability::Ready | AccountAvailability::Unknown
) && eligible_periodic_quota_refresh(account, now)
}
fn access_token_is_current(account: &ProviderAccount, now: SystemTime) -> bool {
account
.access_token_expires_at()
@@ -16,7 +16,6 @@ use gateway_core::provider_ports::{
use secrecy::{ExposeSecret, SecretString};
use thiserror::Error;
use super::identity::CodexAccountIdentityVerifier;
use super::recovery_log::{CodexOAuthRecoveryOperation, record_oauth_recovery};
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
use super::token_client::{RefreshFailure, TokenPair, TokenRefresher};
@@ -32,6 +31,32 @@ const REFRESH_BACKOFF_MAX_ATTEMPTS: u32 = 5;
const REFRESH_RECOVERY_DELAY: Duration = Duration::from_secs(10 * 60);
/// 过期 AT 仍允许 RT 恢复的最长窗口。
const REFRESH_RECOVERY_WINDOW: Duration = Duration::from_secs(24 * 60 * 60);
/// 首次 OAuth 入库最多提前五分钟刷新,避免一小时 token 配合一小时安全边际立刻消耗 RT。
const INITIAL_OAUTH_REFRESH_MAX_MARGIN: Duration = Duration::from_secs(5 * 60);
/// 计算首次 OAuth account 写入时的刷新时间。
///
/// 首次 code exchange 已经拿到一组新的 AT/RT;即使全局安全边际大于等于 AT
/// 生命周期,也不能让它立刻进入 RT 刷新队列。后续刷新仍使用
/// [`ProviderRefreshPolicy::next_attempt_at`] 的原有语义。
#[must_use]
pub(crate) fn initial_oauth_refresh_at(
policy: ProviderRefreshPolicy,
access_token_expires_at: Option<SystemTime>,
observed_at: SystemTime,
has_refresh_token: bool,
) -> Option<SystemTime> {
if !has_refresh_token {
return None;
}
let expires_at = access_token_expires_at?;
let remaining = expires_at.duration_since(observed_at).ok()?;
let initial_margin = policy
.margin()
.min(INITIAL_OAUTH_REFRESH_MAX_MARGIN)
.min(remaining / 2);
expires_at.checked_sub(initial_margin)
}
pub(crate) fn refresh_recovery_deadline(
access_token_expires_at: Option<SystemTime>,
@@ -175,7 +200,6 @@ impl CodexCredentialRefreshService {
pub fn new(
repository: CodexCredentialRepository,
refresher: Arc<dyn TokenRefresher>,
_identity: Arc<dyn CodexAccountIdentityVerifier>,
leases: Arc<dyn ProviderLeasePort>,
credential_state: Arc<dyn ProviderCredentialStatePort>,
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
@@ -405,24 +429,23 @@ impl CodexCredentialRefreshService {
.as_ref()
.map(ExposeSecret::expose_secret),
);
if secret.access_token.expose_secret().is_empty() || expires_in.is_zero() {
return Err(CodexCredentialRefreshError::InvalidRefreshResponse);
}
let observed_at = SystemTime::now();
let access_token_expires_at = observed_at
.checked_add(expires_in)
.ok_or(CodexCredentialRefreshError::InvalidRefreshResponse)?;
let next_refresh_at = policy
.next_attempt_at(due.account.id(), access_token_expires_at, observed_at)
.map(DateTime::<Utc>::from)
.map_err(CodexCredentialRefreshError::from)?;
let access_token_expires_at = expires_in.and_then(|value| observed_at.checked_add(value));
let next_refresh_at = match access_token_expires_at {
Some(expires_at) => Some(DateTime::<Utc>::from(
policy
.next_attempt_at(due.account.id(), expires_at, observed_at)
.map_err(CodexCredentialRefreshError::from)?,
)),
None => None,
};
let result = self
.repository
.rotate_refreshed_oauth_secret(
&due.account,
secret,
access_token_expires_at,
SystemTime::from(next_refresh_at),
next_refresh_at.map(SystemTime::from),
)
.await;
match result {
@@ -435,8 +458,8 @@ impl CodexCredentialRefreshService {
tracing::info!(
account_id = %due.account.id(),
credential_revision = revision.get(),
access_token_expires_at = %DateTime::<Utc>::from(access_token_expires_at),
next_refresh_at = %next_refresh_at,
access_token_expires_at = ?access_token_expires_at.map(DateTime::<Utc>::from),
next_refresh_at = ?next_refresh_at,
"OpenAI OAuth refresh succeeded"
);
Ok(CodexCredentialRefreshOutcome::Refreshed {
@@ -6,7 +6,7 @@ use std::time::SystemTime;
use chrono::{DateTime, Utc};
use gateway_core::engine::credential::{
AccountAvailability, AccountStateChange, CredentialCasOutcome, CredentialCasUpdate,
CredentialRevision, LoadedCredential, ProviderAccount, ProviderAccountId, ProviderAccountStore,
CredentialRevision, ProviderAccount, ProviderAccountId, ProviderAccountStore,
ProviderAccountUpdate,
};
use gateway_core::routing::ProviderKind;
@@ -14,9 +14,7 @@ use secrecy::ExposeSecret;
use thiserror::Error;
use super::security::{CodexCredentialCodec, CodexCredentialDataError, CodexRuntimeCredential};
use super::types::{
CodexAccountProfile, CodexCredentialData, CodexOAuthSecret, RotateCodexCredential,
};
use super::types::{CodexCredentialData, CodexOAuthSecret, RotateCodexCredential};
const PROVIDER_NAME: &str = "openai";
@@ -46,7 +44,6 @@ impl CodexCredentialRepository {
.map_err(|_| CredentialRepositoryError::InvalidInput("credential_revision"))?;
let current = self.store.load_credential(&account_id, expected).await?;
let mut data = CodexCredentialCodec::decode_complete(&current.credential)?;
verify_identity(&current, &data, &input.verified_account)?;
let oauth = data
.oauth_mut()
.ok_or(CredentialRepositoryError::InvalidCredentialData)?;
@@ -90,8 +87,8 @@ impl CodexCredentialRepository {
&self,
account: &ProviderAccount,
secret: CodexOAuthSecret,
access_token_expires_at: SystemTime,
next_refresh_at: SystemTime,
access_token_expires_at: Option<SystemTime>,
next_refresh_at: Option<SystemTime>,
) -> Result<CredentialRevision, CredentialRepositoryError> {
let current = self
.store
@@ -120,8 +117,8 @@ impl CodexCredentialRepository {
unchanged_profile(account),
credential,
secret.refresh_token.is_some(),
Some(access_token_expires_at),
Some(next_refresh_at),
access_token_expires_at,
next_refresh_at,
)
.map_err(|_| CredentialRepositoryError::InvalidCredentialData)?;
cas_revision(self.store.compare_and_swap_credential(update).await?)
@@ -276,24 +273,6 @@ fn cas_revision(
}
}
fn verify_identity(
current: &LoadedCredential,
credential: &CodexCredentialData,
verified: &CodexAccountProfile,
) -> Result<(), CredentialRepositoryError> {
if current.account.provider().as_str() != PROVIDER_NAME
|| current.account.upstream_account_id() != Some(verified.chatgpt_account_id.as_str())
|| verified.chatgpt_user_id != current.account.upstream_user_id()
|| credential.oauth().is_none_or(|credential| {
verified.oauth_subject != credential.principal.oauth_subject
|| verified.poid != credential.principal.poid
})
{
return Err(CredentialRepositoryError::IdentityMismatch);
}
Ok(())
}
fn required_time(value: Option<DateTime<Utc>>) -> Result<SystemTime, CredentialRepositoryError> {
value
.map(SystemTime::from)
@@ -310,8 +289,6 @@ pub enum CredentialRepositoryError {
InvalidInput(&'static str),
#[error("Codex credential data is invalid")]
InvalidCredentialData,
#[error("Codex credential identity does not match")]
IdentityMismatch,
#[error("Codex credential revision conflict")]
RevisionConflict,
#[error("provider account store is unavailable")]
@@ -102,12 +102,32 @@ impl CodexCredentialCodec {
account: &CodexAccountProfile,
cookies: Vec<CodexCookie>,
) -> Result<PlaintextCredential, CodexCredentialDataError> {
Self::encode_complete(CodexCredentialData::OAuth(CodexOAuthCredentialData {
schema_version: CODEX_CREDENTIAL_SCHEMA_VERSION,
principal: CodexCredentialPrincipal {
Self::encode_oauth(
secret,
Some(CodexCredentialPrincipal {
oauth_subject: account.oauth_subject.clone(),
poid: account.poid.clone(),
},
}),
cookies,
)
}
/// 为尚未解析资料的 OAuth 账号编码凭据。
pub(crate) fn encode_unresolved(
secret: &CodexOAuthSecret,
cookies: Vec<CodexCookie>,
) -> Result<PlaintextCredential, CodexCredentialDataError> {
Self::encode_oauth(secret, None, cookies)
}
fn encode_oauth(
secret: &CodexOAuthSecret,
principal: Option<CodexCredentialPrincipal>,
cookies: Vec<CodexCookie>,
) -> Result<PlaintextCredential, CodexCredentialDataError> {
Self::encode_complete(CodexCredentialData::OAuth(CodexOAuthCredentialData {
schema_version: CODEX_CREDENTIAL_SCHEMA_VERSION,
principal,
installation_id: uuid::Uuid::new_v4().to_string(),
access_token: secret.access_token.expose_secret().to_owned(),
refresh_token: secret
@@ -171,7 +191,7 @@ impl CodexCredentialCodec {
refresh_token: data.refresh_token.map(SecretString::from),
id_token: data.id_token.map(SecretString::from),
}),
Some(data.principal),
data.principal,
data.installation_id,
data.cookies,
data.oauth_client_id,
@@ -232,9 +252,6 @@ impl CodexCredentialCodec {
let existing = Self::decode_complete(existing)?;
match (&mut incoming, existing) {
(CodexCredentialData::OAuth(incoming), CodexCredentialData::OAuth(existing)) => {
if incoming.principal != existing.principal {
return Err(CodexCredentialDataError::Invalid);
}
incoming.installation_id = existing.installation_id;
}
(
@@ -260,23 +277,7 @@ impl CodexCredentialCodec {
fn validate(data: &CodexCredentialData) -> Result<(), CodexCredentialDataError> {
let (installation_id, cookies) = match data {
CodexCredentialData::OAuth(data) => {
if data.schema_version != CODEX_CREDENTIAL_SCHEMA_VERSION
|| !valid_identity(&data.principal.oauth_subject)
|| data
.principal
.poid
.as_deref()
.is_some_and(|value| !valid_identity(value))
|| !valid_secret(&data.access_token)
|| data
.refresh_token
.as_deref()
.is_some_and(|value| !valid_secret(value))
|| data
.id_token
.as_deref()
.is_some_and(|value| !valid_secret(value))
{
if data.schema_version != CODEX_CREDENTIAL_SCHEMA_VERSION {
return Err(CodexCredentialDataError::Invalid);
}
(&data.installation_id, &data.cookies)
@@ -1202,8 +1202,7 @@ impl From<CredentialRepositoryError> for CredentialSelectionError {
fn from(error: CredentialRepositoryError) -> Self {
match error {
CredentialRepositoryError::InvalidInput(_)
| CredentialRepositoryError::InvalidCredentialData
| CredentialRepositoryError::IdentityMismatch => Self::InvalidCredential,
| CredentialRepositoryError::InvalidCredentialData => Self::InvalidCredential,
CredentialRepositoryError::RevisionConflict | CredentialRepositoryError::Store => {
Self::Store
}
@@ -7,7 +7,6 @@ use serde::Deserialize;
use std::fmt;
use std::time::Duration;
const MAX_TOKEN_LIFETIME_SECONDS: u64 = 366 * 24 * 60 * 60;
const MAX_OAUTH_RESPONSE_BYTES: usize = 64 * 1024;
const TOKEN_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const TOKEN_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
@@ -24,7 +23,9 @@ pub const OFFICIAL_CODEX_REDIRECT_URI: &str = "http://localhost:1455/auth/callba
pub struct TokenPair {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_in: Duration,
/// OAuth endpoint 可以省略或返回零 expiry;这不影响已经成功的 token
/// exchange,只意味着调用方不能据此安排下一次刷新。
pub expires_in: Option<Duration>,
}
impl fmt::Debug for TokenPair {
@@ -76,11 +77,14 @@ impl fmt::Debug for AuthorizationCodeGrant {
}
}
/// 官方 token endpoint 完成协议校验后返回的 token set。
/// 官方 token endpoint 返回的 token set。
///
/// 与官方首次 authorization-code exchange 一致:`id_token`、`access_token` 与
/// `refresh_token` 都是响应的必填字段。这里不检查 token 内容、签名或 claims。
pub struct AuthorizationTokenSet {
pub secret: crate::credential::CodexOAuthSecret,
pub id_token: SecretString,
pub expires_in: Duration,
pub expires_in: Option<Duration>,
}
impl fmt::Debug for AuthorizationTokenSet {
@@ -170,16 +174,15 @@ pub fn official_openai_token_client() -> Result<OpenAiTokenClient, TokenClientBu
struct RefreshTokenResponse {
access_token: String,
refresh_token: Option<String>,
expires_in: u64,
expires_in: Option<u64>,
}
#[derive(Deserialize)]
struct AuthorizationCodeResponse {
access_token: String,
refresh_token: Option<String>,
refresh_token: String,
id_token: String,
token_type: String,
expires_in: u64,
expires_in: Option<u64>,
}
#[async_trait]
@@ -229,13 +232,6 @@ impl AuthorizationCodeExchanger for OpenAiTokenClient {
AuthorizationCodeExchangeError::Ambiguous
}
})?;
let success_is_json = response.status().is_success()
&& response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(';').next())
.is_some_and(|value| value.trim().eq_ignore_ascii_case("application/json"));
let (status, body) = read_bounded_response(response)
.await
.map_err(|_| AuthorizationCodeExchangeError::Ambiguous)?;
@@ -245,31 +241,17 @@ impl AuthorizationCodeExchanger for OpenAiTokenClient {
_ => AuthorizationCodeExchangeError::Rejected,
});
}
if !success_is_json {
return Err(AuthorizationCodeExchangeError::Rejected);
}
let tokens = serde_json::from_slice::<AuthorizationCodeResponse>(&body)
.map_err(|_| AuthorizationCodeExchangeError::Rejected)?;
if tokens.access_token.is_empty()
|| tokens.id_token.is_empty()
|| !tokens.token_type.eq_ignore_ascii_case("bearer")
|| tokens.expires_in == 0
|| tokens.expires_in > MAX_TOKEN_LIFETIME_SECONDS
|| tokens
.refresh_token
.as_deref()
.is_some_and(|token| token.trim().is_empty())
{
return Err(AuthorizationCodeExchangeError::Rejected);
}
let id_token = SecretString::from(tokens.id_token);
Ok(AuthorizationTokenSet {
secret: crate::credential::CodexOAuthSecret {
access_token: SecretString::from(tokens.access_token),
refresh_token: tokens.refresh_token.map(SecretString::from),
refresh_token: Some(SecretString::from(tokens.refresh_token)),
id_token: None,
},
id_token: SecretString::from(tokens.id_token),
expires_in: Duration::from_secs(tokens.expires_in),
id_token,
expires_in: optional_expiry(tokens.expires_in),
})
}
}
@@ -297,23 +279,20 @@ async fn read_bounded_response(
fn parse_token_pair(body: &[u8]) -> Result<TokenPair, ()> {
let tokens = serde_json::from_slice::<RefreshTokenResponse>(body).map_err(|_| ())?;
if tokens.access_token.trim().is_empty() {
return Err(());
}
if tokens.expires_in == 0 || tokens.expires_in > MAX_TOKEN_LIFETIME_SECONDS {
return Err(());
}
Ok(TokenPair {
access_token: tokens.access_token,
// OAuth 刷新响应可能省略未变更的 RT。将空字段同样视为省略,调用方
// 才能保留当前仍可使用的 token。
refresh_token: tokens
.refresh_token
.filter(|refresh_token| !refresh_token.trim().is_empty()),
expires_in: Duration::from_secs(tokens.expires_in),
// OAuth 刷新响应可能省略未变更的 RT;缺失时由调用方保留当前值。
refresh_token: tokens.refresh_token,
expires_in: optional_expiry(tokens.expires_in),
})
}
fn optional_expiry(value: Option<u64>) -> Option<Duration> {
value
.filter(|seconds| *seconds > 0)
.map(Duration::from_secs)
}
fn classify_refresh_failure(status: StatusCode, body: &[u8]) -> RefreshFailure {
// 5xx 与 429 按状态码判为瞬态:其正文(CDN/网关页等)不是权威 OAuth 错误响应,
// 不据此终态失效账号。
@@ -2,9 +2,10 @@
use std::fmt;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, Utc};
use secrecy::SecretString;
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use url::Url;
/// OAuth AT/RT/ID Token;`Debug` 永不输出明文。
@@ -41,6 +42,99 @@ pub struct CodexAccountProfile {
pub access_token_expires_at: Option<DateTime<Utc>>,
}
/// 从 OAuth ID token payload 尽力提取的展示资料。
///
/// 这只是与官方客户端一致的 base64 JSON 读取,不校验 JWT 签名或 claims。
#[derive(Clone, Default)]
pub(crate) struct CodexOAuthMetadata {
pub(crate) email: Option<String>,
pub(crate) chatgpt_plan_type: Option<String>,
pub(crate) chatgpt_user_id: Option<String>,
pub(crate) chatgpt_account_id: Option<String>,
}
impl fmt::Debug for CodexOAuthMetadata {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CodexOAuthMetadata")
.field("email", &self.email.as_ref().map(|_| "<redacted>"))
.field(
"chatgpt_user_id",
&self.chatgpt_user_id.as_ref().map(|_| "<redacted>"),
)
.field(
"chatgpt_account_id",
&self.chatgpt_account_id.as_ref().map(|_| "<redacted>"),
)
.field("chatgpt_plan_type", &self.chatgpt_plan_type)
.finish()
}
}
/// 按官方 `token_data.rs` 的字段优先级读取 ID token payload。
///
/// 与官方一致,`id_token` 的 JWT 外形、payload base64 或 JSON 无法解析时返回错误;
/// 这不是签名或 claims 验证。各身份字段本身仍全部可缺失。
pub(crate) fn parse_id_token_metadata(jwt: &str) -> Result<CodexOAuthMetadata, ()> {
let claims = decode_jwt_payload::<IdTokenClaims>(jwt)?;
let email = claims
.email
.or_else(|| claims.profile.and_then(|profile| profile.email));
let Some(auth) = claims.auth else {
return Ok(CodexOAuthMetadata {
email,
..CodexOAuthMetadata::default()
});
};
Ok(CodexOAuthMetadata {
email,
chatgpt_plan_type: auth.chatgpt_plan_type,
chatgpt_user_id: auth.chatgpt_user_id.or(auth.user_id),
chatgpt_account_id: auth.chatgpt_account_id,
})
}
#[derive(Deserialize)]
struct IdTokenClaims {
#[serde(default)]
email: Option<String>,
#[serde(rename = "https://api.openai.com/profile", default)]
profile: Option<ProfileClaims>,
#[serde(rename = "https://api.openai.com/auth", default)]
auth: Option<AuthClaims>,
}
#[derive(Deserialize)]
struct ProfileClaims {
#[serde(default)]
email: Option<String>,
}
#[derive(Deserialize)]
struct AuthClaims {
#[serde(default)]
chatgpt_plan_type: Option<String>,
#[serde(default)]
chatgpt_user_id: Option<String>,
#[serde(default)]
user_id: Option<String>,
#[serde(default)]
chatgpt_account_id: Option<String>,
}
fn decode_jwt_payload<T: DeserializeOwned>(jwt: &str) -> Result<T, ()> {
let mut parts = jwt.split('.');
let (Some(header), Some(payload), Some(signature)) = (parts.next(), parts.next(), parts.next())
else {
return Err(());
};
if header.is_empty() || payload.is_empty() || signature.is_empty() {
return Err(());
}
let payload = URL_SAFE_NO_PAD.decode(payload).map_err(|_| ())?;
serde_json::from_slice(&payload).map_err(|_| ())
}
impl fmt::Debug for CodexAccountProfile {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
@@ -109,7 +203,8 @@ pub const CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY: &str = "agent_identity";
#[serde(deny_unknown_fields)]
pub struct CodexOAuthCredentialData {
pub schema_version: u32,
pub principal: CodexCredentialPrincipal,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub principal: Option<CodexCredentialPrincipal>,
pub installation_id: String,
pub access_token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
+2 -24
View File
@@ -16,13 +16,10 @@ use gateway_core::task::WorkerContribution;
use crate::admin::{OpenAiAdminProvider, OpenAiAdminServices, OpenAiOAuthPendingStore};
use crate::credential::token_client::{AuthorizationCodeExchanger, TokenRefresher};
use crate::credential::{
CodexAccountIdentityService, CodexAccountIdentityVerifier, CodexAgentIdentityTaskService,
CodexAuthenticatedAccountSource, CodexCookiePolicy, CodexCredentialAdmin,
CodexAgentIdentityTaskService, CodexCookiePolicy, CodexCredentialAdmin,
CodexCredentialAdminService, CodexCredentialCatalogService, CodexCredentialQuotaService,
CodexCredentialRefreshService, CodexCredentialRepository, CodexCredentialSelector,
CodexJwtIdentityVerifier, CodexOAuthAdmin, CodexOAuthAdminService, CodexSignedIdentityVerifier,
OfficialCodexAgentIdentityTaskRegistrar, ReqwestCodexAuthenticatedAccountSource,
ReqwestOpenAiJwksSource,
CodexOAuthAdmin, CodexOAuthAdminService, OfficialCodexAgentIdentityTaskRegistrar,
};
use crate::transport::profile::{
CodexCliReleaseService, CodexDesktopReleaseService, OfficialCodexCliReleaseTransport,
@@ -148,30 +145,16 @@ pub async fn initialize(
credential::token_client::openai_token_client(config.token_client_config())
.map_err(|_| OpenAiInitializeError::TokenClient)?,
);
let jwks = ReqwestOpenAiJwksSource::new().map_err(|_| OpenAiInitializeError::Identity)?;
let signed: Arc<dyn CodexSignedIdentityVerifier> = Arc::new(
CodexJwtIdentityVerifier::new(Box::new(jwks))
.with_oauth_client_id(config.oauth_client_id()),
);
let account_source: Arc<dyn CodexAuthenticatedAccountSource> = Arc::new(
ReqwestCodexAuthenticatedAccountSource::new(profile.clone(), config.base_url().to_owned())
.map_err(|_| OpenAiInitializeError::Identity)?,
);
let identity: Arc<dyn CodexAccountIdentityVerifier> = Arc::new(
CodexAccountIdentityService::new(signed, Arc::clone(&account_source)),
);
let refresher: Arc<dyn TokenRefresher> = token_client.clone();
let exchanger: Arc<dyn AuthorizationCodeExchanger> = token_client;
let credential_admin = Arc::new(CodexCredentialAdminService::new(
Arc::clone(&refresher),
Arc::clone(&account_source),
Arc::clone(&leases),
Arc::clone(&runtime_policy),
));
let refresh = Arc::new(CodexCredentialRefreshService::new(
repository,
refresher,
Arc::clone(&identity),
Arc::clone(&leases),
credential_state,
Arc::clone(&runtime_policy),
@@ -184,7 +167,6 @@ pub async fn initialize(
CodexOAuthAdminService::new(
pending,
exchanger,
account_source,
Arc::clone(&accounts),
Arc::clone(&runtime_policy),
CodexCredentialAdmin,
@@ -197,11 +179,9 @@ pub async fn initialize(
accounts,
OpenAiAdminServices {
credentials: credential_admin,
verifier: identity,
oauth: oauth_admin,
quota: Arc::clone(&quota),
catalog: Arc::clone(&catalog),
runtime_policy,
},
websocket_pool,
desktop_release_status,
@@ -260,8 +240,6 @@ pub enum OpenAiInitializeError {
CookiePolicy,
#[error("OpenAI token client could not initialize")]
TokenClient,
#[error("OpenAI identity verifier could not initialize")]
Identity,
#[error("OpenAI Agent Identity service could not initialize")]
AgentIdentity,
#[error("OpenAI credential administration could not initialize")]
@@ -765,7 +765,7 @@ fn account_record(account: &ProviderAccount) -> AccountRecord {
provider_kind: account.provider().clone(),
name: account.name().to_owned(),
email: account.email().map(str::to_owned),
upstream_user_id: account.upstream_user_id().to_owned(),
upstream_user_id: account.upstream_user_id().map(str::to_owned),
upstream_account_id: account.upstream_account_id().map(str::to_owned),
plan_type: account.plan_type().map(str::to_owned),
authentication_kind: account.authentication_kind().to_owned(),
File diff suppressed because it is too large Load Diff
@@ -24,7 +24,6 @@ use reqwest::StatusCode;
use secrecy::ExposeSecret as _;
use serde_json::Value;
use super::admin::{import_service, unused_import_refresher};
use crate::support::MemoryAccountStore;
fn signing_fixture(seed: u8) -> (SigningKey, String) {
@@ -56,7 +55,7 @@ fn agent_account(
account_id,
provider,
"Agent Identity test".to_owned(),
"agent-user".to_owned(),
Some("agent-user".to_owned()),
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY.to_owned(),
CredentialRevision::new(1).expect("revision"),
None,
@@ -232,29 +231,3 @@ async fn task_registration_and_recovery_are_revision_fenced() {
assert_eq!(fenced.account.revision().get(), 3);
assert_eq!(registrar.calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn agent_identity_import_accepts_the_minimal_document_shape() {
let prepared = import_service(unused_import_refresher())
.prepare_import_document(serde_json::json!({
"auth_mode": "agentIdentity",
"agent_identity": {
"agent_runtime_id": "runtime-minimal",
"agent_private_key": signing_fixture(9).1,
"account_id": "chatgpt-minimal",
"chatgpt_user_id": "user-minimal",
"email": "minimal@example.com",
"plan_type": "pro"
}
}))
.await
.expect("minimal Agent Identity import");
assert_eq!(prepared.accounts().len(), 1);
let account = &prepared.accounts()[0].account;
assert_eq!(
account.authentication_kind(),
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY
);
assert_eq!(account.access_token_expires_at(), None);
assert!(!format!("{prepared:?}").contains("agent_private_key"));
}
@@ -234,7 +234,7 @@ fn codec_reimport_preserves_existing_installation_id_for_the_same_principal() {
}
#[test]
fn codec_reimport_rejects_installation_reuse_across_principals() {
fn codec_reimport_preserves_installation_id_without_principal_validation() {
let existing = CodexCredentialCodec::encode_new(
&secret("existing-access-token"),
&profile("chatgpt-existing-principal"),
@@ -248,7 +248,19 @@ fn codec_reimport_rejects_installation_reuse_across_principals() {
)
.expect("incoming credential");
assert!(CodexCredentialCodec::preserve_installation_id(&incoming, &existing).is_err());
let existing_id = CodexCredentialCodec::decode_complete(&existing)
.expect("existing data")
.installation_id()
.to_owned();
let preserved = CodexCredentialCodec::preserve_installation_id(&incoming, &existing)
.expect("preserve installation ID");
let preserved = CodexCredentialCodec::decode_complete(&preserved).expect("preserved data");
assert_eq!(preserved.installation_id(), existing_id);
assert_eq!(
preserved.oauth().expect("OAuth data").access_token,
"incoming-access-token"
);
}
#[test]
@@ -1,629 +0,0 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use async_trait::async_trait;
use jsonwebtoken::{Algorithm, EncodingKey, Header, encode, get_current_timestamp};
use provider_openai::credential::token_client::OFFICIAL_CODEX_OAUTH_CLIENT_ID;
use provider_openai::credential::{
CodexAccountIdentityService, CodexAccountIdentityVerifier, CodexAuthenticatedAccount,
CodexAuthenticatedAccountSource, CodexIdentityExpectation, CodexIdentityVerification,
CodexIdentityVerificationError, CodexJwksSource, CodexJwtIdentityVerifier, CodexOAuthSecret,
CodexSignedIdentityVerifier, OFFICIAL_OPENAI_API_AUDIENCE, OFFICIAL_OPENAI_ISSUER,
ReqwestOpenAiJwksSource,
};
use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize;
use serde_json::{Value, json};
const KEY_ID: &str = "codex-test-key";
const RSA_MODULUS: &str = "p6GpjR8xbWJF2X63CJL02fTHBmfhfEGSqckUQaAT7KYubw95XhpwQ-V2IQcMm8K-ckx4hTd1CfqPjz0xgZzFRxns2BD_lUWYazDrsNISW23Y25rhg8GoC5ad9msdJlEARgyEFA2WQxqKg9KDpV8aZmVOSUkUjujF7VPijQ63ogNzSxAhBPbctSIzhcJApO6KV97j4OizhynWxJtCzmCEsxVvoLVNOXWLTJ0JW3ogs7dY8Unf3PTkci7QQhslJRSaCuy_6HWpP-Z94ayfKyo8nhz0PQsXvTl3y_8ARtwOT9sywmrd8nkTZ1obi5QC3DUZLHtenIk--rJLhvGn0klevw";
const RSA_EXPONENT: &str = "AQAB";
const PRIVATE_KEY: &str = r#"-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCnoamNHzFtYkXZ
frcIkvTZ9McGZ+F8QZKpyRRBoBPspi5vD3leGnBD5XYhBwybwr5yTHiFN3UJ+o+P
PTGBnMVHGezYEP+VRZhrMOuw0hJbbdjbmuGDwagLlp32ax0mUQBGDIQUDZZDGoqD
0oOlXxpmZU5JSRSO6MXtU+KNDreiA3NLECEE9ty1IjOFwkCk7opX3uPg6LOHKdbE
m0LOYISzFW+gtU05dYtMnQlbeiCzt1jxSd/c9ORyLtBCGyUlFJoK7L/odak/5n3h
rJ8rKjyeHPQ9Cxe9OXfL/wBG3A5P2zLCat3yeRNnWhuLlALcNRkse16ciT76skuG
8afSSV6/AgMBAAECggEATnDEkUfWbjP9MYAtD/MMZm03MJUtm2JnydFRDEBm8mb0
9X1NMq62d98I/v14de6UBZIpF1VTDB9WTBW7c8YzPkkwXg7eOjB92TLkJzK16lil
kGWSP4BALWQGeMe4tTRUJ3M13thYxv/Fb1fzyE+PX/jpwDtVRmZAMAT/zzfi0AmB
ZoVcN0ipr4cd8xq6SEk3rLz4UqstX4qZ58U0Yqhi9q5YVzMrwV4GXHf80zaq1DYn
m5Unddq1Igs0HDjTbHtY0jyQW3IK1fNr0DLvmw52AeQD/xYWB83EWDPITrMFuNa4
GYEGlnawalLyteh1/mgJZ/F/gqNcWNefkN5FDqtXIQKBgQDR/nriVyk12QXzDR91
7CAJRrEMl3QlvRgfqdVYveiC7osvM8MHQzE+yEfZYTMvOhBs4J5QH0bIfcLiGgeK
nyXnDqJx+gJr4dYOX2Ma/DT1wghItPyxCFsbGvL0c0LDewaL3NcQyPIFip6O2f2Z
jjlrHnXThbtcByH2y9i/oVWJ0QKBgQDMW0bpcXNBIfllZDQOUl4PfPnxTS67qZjC
3QpqHqdKqNs7nNutJNLuOaJig14stjTm5EdliwZPvOPAnbjEOdP5vG1GxwD3GKRt
G6MdPT2TdKCHaPOv/gE6tU2nt2wk7OsmTE20aDd8Czwc3l8ok8/KAM7YXOjbsg8y
StacIrDzjwKBgGD89Uqm0RzSwKGyVO6FAYLnSIy1QurPPF4bdbNH+yTGAjjp4lDv
YsZJgI3RC+/rFH0E/XmqCGo/U4xUU1leCgZ+xt53hzjGlLQMsFxdUiST2nmiRfeK
EXIib0YiGhrpLmvYsuhItyeCD5uQ6UVL4A8ugWMRqChoDvLK0bAoVraRAoGBAKig
EO2EHfR60k4l1waSVOc22w+P/qX/lfwFZRiX3rcuimiwUFyY7CyDBkl/2O/QEesM
JxXVGAon9U71VockqJOENi+W+mbqwJL/oSL5a5wHOodBxQNN9zm4bTGdmvEFRiw9
/kiFTnNe2eWAYMkc3vLyo7vJPqQ6U2vOcTQ5NAZlAoGBAMBeXrXAO/+cAUt41oVE
Qoul7TG+QFTN4A650QVbHQZsSDsinLzgPQ8TLLFtb180OptXSvIR+OGfc0E9JcJe
xDWp3FkNixBAikmMzlBqq39+numIilltPwT4RLib4OP6x8gZZHUB/+3HIYmnxk/G
b9ZuffSh1GPmnXSyKb7rtg6M
-----END PRIVATE KEY-----"#;
struct StaticJwksSource {
body: Vec<u8>,
calls: Arc<AtomicUsize>,
error: Option<CodexIdentityVerificationError>,
}
#[async_trait]
impl CodexJwksSource for StaticJwksSource {
async fn fetch(&self) -> Result<Vec<u8>, CodexIdentityVerificationError> {
self.calls.fetch_add(1, Ordering::SeqCst);
if let Some(error) = self.error {
return Err(error);
}
Ok(self.body.clone())
}
}
struct StaticAccountSource {
result: Result<CodexAuthenticatedAccount, CodexIdentityVerificationError>,
}
#[async_trait]
impl CodexAuthenticatedAccountSource for StaticAccountSource {
async fn fetch(
&self,
_secret: &CodexOAuthSecret,
_expectation: &CodexIdentityExpectation,
) -> Result<CodexAuthenticatedAccount, CodexIdentityVerificationError> {
self.result.clone()
}
}
fn verifier() -> (CodexJwtIdentityVerifier, Arc<AtomicUsize>) {
let calls = Arc::new(AtomicUsize::new(0));
let body = serde_json::to_vec(&json!({
"keys": [{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": KEY_ID,
"n": RSA_MODULUS,
"e": RSA_EXPONENT
}]
}))
.expect("serialize test JWKS");
let verifier = CodexJwtIdentityVerifier::new(Box::new(StaticJwksSource {
body,
calls: Arc::clone(&calls),
error: None,
}));
(verifier, calls)
}
fn claims(exp: u64) -> Value {
let now = get_current_timestamp();
json!({
"iss": OFFICIAL_OPENAI_ISSUER,
"aud": [OFFICIAL_OPENAI_API_AUDIENCE],
"iat": now.saturating_sub(1),
"nbf": now.saturating_sub(1),
"exp": exp,
"sub": "subject-signed",
"https://api.openai.com/auth": {
"chatgpt_account_id": "account-signed",
"chatgpt_user_id": "user-signed",
"user_id": "user-signed",
"chatgpt_plan_type": "plus"
},
"https://api.openai.com/profile": {
"email": "signed@example.invalid"
}
})
}
fn token(claims: &Value) -> String {
let mut header = Header::new(Algorithm::RS256);
header.kid = Some(KEY_ID.to_owned());
header.typ = Some("JWT".to_owned());
encode(
&header,
claims,
&EncodingKey::from_rsa_pem(PRIVATE_KEY.as_bytes()).expect("parse signing key"),
)
.expect("sign test access token")
}
fn secret(claims: &Value, refresh_token: Option<&str>) -> CodexOAuthSecret {
CodexOAuthSecret {
access_token: SecretString::new(token(claims).into()),
refresh_token: refresh_token.map(|value| SecretString::new(value.to_owned().into())),
id_token: None,
}
}
pub(crate) async fn signed_identity_fixture() -> provider_openai::credential::CodexSignedIdentity {
let (verifier, _) = verifier();
verifier
.verify_access(&secret(
&claims(get_current_timestamp() + 3_600),
Some("refresh-token"),
))
.await
.expect("signed identity fixture")
}
fn id_token_claims(nonce: &str, subject: &str) -> Value {
let now = get_current_timestamp();
json!({
"iss": OFFICIAL_OPENAI_ISSUER,
"aud": [OFFICIAL_CODEX_OAUTH_CLIENT_ID],
"iat": now.saturating_sub(1),
"nbf": now.saturating_sub(1),
"exp": now + 3_600,
"sub": subject,
"nonce": nonce
})
}
#[tokio::test]
async fn verifier_should_derive_identity_only_from_valid_signed_claims() {
let (verifier, calls) = verifier();
let profile = verifier
.verify_access(&secret(
&claims(get_current_timestamp() + 3_600),
Some("refresh-token"),
))
.await
.expect("verify signed Codex token");
assert_eq!(profile.oauth_subject(), "subject-signed");
assert_eq!(profile.poid(), None);
assert_eq!(profile.claimed_account_id(), Some("account-signed"));
assert_eq!(profile.claimed_user_id(), Some("user-signed"));
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn verifier_should_accept_current_signed_poid_account_identity() {
let (verifier, _) = verifier();
let mut current = claims(get_current_timestamp() + 3_600);
current["https://api.openai.com/auth"]
.as_object_mut()
.expect("auth claims")
.remove("chatgpt_account_id");
current["https://api.openai.com/auth"]["poid"] = json!("organization-signed");
let profile = verifier
.verify_access(&secret(&current, Some("refresh-token")))
.await
.expect("verify current signed Codex token");
assert_eq!(profile.oauth_subject(), "subject-signed");
assert_eq!(profile.poid(), Some("organization-signed"));
assert_eq!(profile.claimed_account_id(), None);
}
#[tokio::test]
async fn account_identity_requires_matching_authenticated_usage_facts() {
let (signed, _) = verifier();
let service = CodexAccountIdentityService::new(
Arc::new(signed),
Arc::new(StaticAccountSource {
result: Ok(CodexAuthenticatedAccount {
chatgpt_account_id: "account-signed".to_owned(),
chatgpt_user_id: "user-signed".to_owned(),
email: Some("usage@example.invalid".to_owned()),
plan_type: Some("pro".to_owned()),
}),
}),
);
let verification = service
.verify(
&secret(&claims(get_current_timestamp() + 3_600), None),
&CodexIdentityExpectation::imported(
Some("account-signed".to_owned()),
Some("user-signed".to_owned()),
)
.expect("import expectation"),
)
.await
.expect("complete authenticated identity");
let CodexIdentityVerification::Complete(profile) = verification else {
panic!("usage facts must complete the identity");
};
assert_eq!(profile.oauth_subject, "subject-signed");
assert_eq!(profile.chatgpt_account_id, "account-signed");
assert_eq!(profile.chatgpt_user_id, "user-signed");
assert_eq!(profile.email.as_deref(), Some("usage@example.invalid"));
}
#[tokio::test]
async fn account_identity_rejects_imported_workspace_conflict() {
let (signed, _) = verifier();
let service = CodexAccountIdentityService::new(
Arc::new(signed),
Arc::new(StaticAccountSource {
result: Ok(CodexAuthenticatedAccount {
chatgpt_account_id: "account-signed".to_owned(),
chatgpt_user_id: "user-signed".to_owned(),
email: None,
plan_type: None,
}),
}),
);
let error = service
.verify(
&secret(&claims(get_current_timestamp() + 3_600), None),
&CodexIdentityExpectation::imported(Some("other-account".to_owned()), None)
.expect("import expectation"),
)
.await
.expect_err("document workspace cannot override authenticated usage");
assert_eq!(error, CodexIdentityVerificationError::Rejected);
}
#[tokio::test]
async fn import_identity_uses_signed_claims_when_usage_rejects_the_request() {
let (signed, _) = verifier();
let service = CodexAccountIdentityService::new(
Arc::new(signed),
Arc::new(StaticAccountSource {
result: Err(CodexIdentityVerificationError::Rejected),
}),
);
let profile = service
.verify_import(&secret(
&claims(get_current_timestamp() + 3_600),
Some("refresh-token"),
))
.await
.expect("signed import identity");
assert_eq!(profile.chatgpt_account_id, "account-signed");
assert_eq!(profile.chatgpt_user_id, "user-signed");
}
#[tokio::test]
async fn import_identity_keeps_signed_workspace_when_usage_returns_a_different_workspace() {
let (signed, _) = verifier();
let service = CodexAccountIdentityService::new(
Arc::new(signed),
Arc::new(StaticAccountSource {
result: Ok(CodexAuthenticatedAccount {
chatgpt_account_id: "account-default".to_owned(),
chatgpt_user_id: "user-default".to_owned(),
email: None,
plan_type: None,
}),
}),
);
let profile = service
.verify_import(&secret(
&claims(get_current_timestamp() + 3_600),
Some("refresh-token"),
))
.await
.expect("signed import identity");
assert_eq!(profile.chatgpt_account_id, "account-signed");
assert_eq!(profile.chatgpt_user_id, "user-signed");
}
#[tokio::test]
async fn authorization_identity_uses_signed_workspace_when_usage_returns_default_workspace() {
let (signed, _) = verifier();
let service = CodexAccountIdentityService::new(
Arc::new(signed),
Arc::new(StaticAccountSource {
result: Ok(CodexAuthenticatedAccount {
chatgpt_account_id: "account-default".to_owned(),
chatgpt_user_id: "user-default".to_owned(),
email: None,
plan_type: None,
}),
}),
);
let nonce = SecretString::from("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
let profile = service
.verify_authorization(
&secret(
&claims(get_current_timestamp() + 3_600),
Some("refresh-token"),
),
&SecretString::from(token(&id_token_claims(
nonce.expose_secret(),
"subject-signed",
))),
&nonce,
&CodexIdentityExpectation::reauthorization(
"account-signed".to_owned(),
"user-signed".to_owned(),
"00000000-0000-4000-8000-000000000001".to_owned(),
)
.expect("reauthorization expectation"),
)
.await
.expect("signed authorization identity")
.into_complete()
.expect("complete signed profile");
assert_eq!(profile.chatgpt_account_id, "account-signed");
assert_eq!(profile.chatgpt_user_id, "user-signed");
}
#[tokio::test]
async fn authorization_identity_accepts_free_token_without_signed_account_id() {
let (signed, _) = verifier();
let service = CodexAccountIdentityService::new(
Arc::new(signed),
Arc::new(StaticAccountSource {
result: Ok(CodexAuthenticatedAccount {
chatgpt_account_id: "account-signed".to_owned(),
chatgpt_user_id: "user-signed".to_owned(),
email: Some("usage@example.invalid".to_owned()),
plan_type: Some("free".to_owned()),
}),
}),
);
let mut free_claims = claims(get_current_timestamp() + 3_600);
free_claims["https://api.openai.com/auth"]
.as_object_mut()
.expect("auth claims")
.remove("chatgpt_account_id");
free_claims["https://api.openai.com/auth"]["poid"] = json!("new-free-poid");
let nonce = SecretString::from("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
let profile = service
.verify_authorization(
&secret(&free_claims, Some("refresh-token")),
&SecretString::from(token(&id_token_claims(
nonce.expose_secret(),
"subject-signed",
))),
&nonce,
&CodexIdentityExpectation::reauthorization(
"account-signed".to_owned(),
"user-signed".to_owned(),
"00000000-0000-4000-8000-000000000001".to_owned(),
)
.expect("reauthorization expectation"),
)
.await
.expect("verified Free authorization identity")
.into_complete()
.expect("complete Free profile");
assert_eq!(profile.chatgpt_account_id, "account-signed");
assert_eq!(profile.chatgpt_user_id, "user-signed");
assert_eq!(profile.poid.as_deref(), Some("new-free-poid"));
}
#[tokio::test]
async fn authorization_identity_rejects_signed_workspace_rebinding() {
let (signed, _) = verifier();
let service = CodexAccountIdentityService::new(
Arc::new(signed),
Arc::new(StaticAccountSource {
result: Ok(CodexAuthenticatedAccount {
chatgpt_account_id: "account-default".to_owned(),
chatgpt_user_id: "user-default".to_owned(),
email: None,
plan_type: None,
}),
}),
);
let nonce = SecretString::from("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
let error = service
.verify_authorization(
&secret(
&claims(get_current_timestamp() + 3_600),
Some("refresh-token"),
),
&SecretString::from(token(&id_token_claims(
nonce.expose_secret(),
"subject-signed",
))),
&nonce,
&CodexIdentityExpectation::reauthorization(
"account-target".to_owned(),
"user-target".to_owned(),
"00000000-0000-4000-8000-000000000001".to_owned(),
)
.expect("reauthorization expectation"),
)
.await
.expect_err("signed identity cannot rebind the target account");
assert_eq!(error, CodexIdentityVerificationError::Rejected);
}
#[tokio::test]
async fn account_identity_keeps_usage_unavailable_out_of_ready_profile() {
let (signed, _) = verifier();
let service = CodexAccountIdentityService::new(
Arc::new(signed),
Arc::new(StaticAccountSource {
result: Err(CodexIdentityVerificationError::Unavailable),
}),
);
let verification = service
.verify(
&secret(&claims(get_current_timestamp() + 3_600), None),
&CodexIdentityExpectation::default(),
)
.await
.expect("signed identity remains explicit");
assert!(matches!(
&verification,
CodexIdentityVerification::SignedOnly(_)
));
assert_eq!(
verification.into_complete().expect_err("not Ready"),
CodexIdentityVerificationError::Unavailable
);
}
#[tokio::test]
async fn verifier_should_reject_expired_wrong_issuer_audience_or_unbound_tokens() {
let (verifier, _) = verifier();
let now = get_current_timestamp();
let mut cases = vec![
claims(now),
claims(now + 3_600),
claims(now + 3_600),
claims(now + 3_600),
];
cases[1]["iss"] = json!("https://attacker.invalid");
cases[2]["aud"] = json!(["https://attacker.invalid"]);
cases[3]["sub"] = json!("");
for claims in cases {
let error = verifier
.verify_access(&secret(&claims, None))
.await
.expect_err("invalid identity token must fail");
assert_eq!(error, CodexIdentityVerificationError::Rejected);
}
}
#[tokio::test]
async fn verifier_should_reject_user_rebinding_and_invalid_refresh_shape() {
let (verifier, _) = verifier();
let mut rebound = claims(get_current_timestamp() + 3_600);
rebound["https://api.openai.com/auth"]["user_id"] = json!("other-user");
let rebound_error = verifier
.verify_access(&secret(&rebound, None))
.await
.expect_err("signed aliases must not disagree");
let access = token(&claims(get_current_timestamp() + 3_600));
let reused = CodexOAuthSecret {
access_token: SecretString::new(access.clone().into()),
refresh_token: Some(SecretString::new(access.into())),
id_token: None,
};
let refresh_error = verifier
.verify_access(&reused)
.await
.expect_err("refresh token must have independent shape");
assert_eq!(rebound_error, CodexIdentityVerificationError::Rejected);
assert_eq!(refresh_error, CodexIdentityVerificationError::Rejected);
}
#[tokio::test]
async fn verifier_should_cache_jwks_and_fail_closed_when_source_is_unavailable() {
let (verifier, calls) = verifier();
let valid = secret(&claims(get_current_timestamp() + 3_600), None);
verifier
.verify_access(&valid)
.await
.expect("first verification");
verifier
.verify_access(&valid)
.await
.expect("cached verification");
assert_eq!(calls.load(Ordering::SeqCst), 1);
let unavailable = CodexJwtIdentityVerifier::new(Box::new(StaticJwksSource {
body: Vec::new(),
calls: Arc::new(AtomicUsize::new(0)),
error: Some(CodexIdentityVerificationError::Unavailable),
}));
let error = unavailable
.verify_access(&valid)
.await
.expect_err("unavailable JWKS must fail closed");
assert_eq!(error, CodexIdentityVerificationError::Unavailable);
}
#[tokio::test]
async fn authorization_verifier_should_bind_signed_id_token_subject_audience_and_nonce() {
let (verifier, _) = verifier();
let access = secret(
&claims(get_current_timestamp() + 3_600),
Some("refresh-token"),
);
let nonce = SecretString::from("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
let valid_id = SecretString::from(token(&id_token_claims(
nonce.expose_secret(),
"subject-signed",
)));
verifier
.verify_authorization(&access, &valid_id, &nonce)
.await
.expect("verify authorization token set");
let wrong_nonce = verifier
.verify_authorization(
&access,
&valid_id,
&SecretString::from("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"),
)
.await
.expect_err("nonce mismatch must fail");
let wrong_subject = verifier
.verify_authorization(
&access,
&SecretString::from(token(&id_token_claims(
nonce.expose_secret(),
"other-subject",
))),
&nonce,
)
.await
.expect_err("subject mismatch must fail");
let mut wrong_audience = id_token_claims(nonce.expose_secret(), "subject-signed");
wrong_audience["aud"] = json!(["attacker-client"]);
let wrong_audience = verifier
.verify_authorization(&access, &SecretString::from(token(&wrong_audience)), &nonce)
.await
.expect_err("ID token audience mismatch must fail");
assert_eq!(wrong_nonce, CodexIdentityVerificationError::Rejected);
assert_eq!(wrong_subject, CodexIdentityVerificationError::Rejected);
assert_eq!(wrong_audience, CodexIdentityVerificationError::Rejected);
}
#[derive(Deserialize)]
struct RealAccountExport {
accounts: Vec<RealAccountWire>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct RealAccountWire {
token: String,
refresh_token: Option<String>,
}
#[tokio::test]
async fn supplied_real_access_only_account_should_pass_official_signature_verification() {
let Ok(path) = std::env::var("CODEX_REAL_ACCOUNT_FILE") else {
return;
};
let bytes = std::fs::read(path).expect("read isolated real account fixture");
let mut document: RealAccountExport =
serde_json::from_slice(&bytes).expect("parse real account fixture shape");
let account = document.accounts.pop().expect("one real account fixture");
assert!(account.refresh_token.is_none());
let verifier = CodexJwtIdentityVerifier::new(Box::new(
ReqwestOpenAiJwksSource::new().expect("build official JWKS source"),
));
let profile = verifier
.verify_access(&CodexOAuthSecret {
access_token: SecretString::new(account.token.into()),
refresh_token: account
.refresh_token
.map(|token| SecretString::new(token.into())),
id_token: None,
})
.await
.expect("verify official access-only Codex account");
assert!(!profile.oauth_subject().is_empty());
assert!(profile.access_token_expires_at() > chrono::Utc::now());
}
@@ -1,80 +1,9 @@
use std::io::{self, Write};
use std::sync::{Arc, Mutex};
use serde_json::Value;
use tokio::sync::{Mutex as AsyncMutex, MutexGuard};
use tracing_subscriber::fmt::MakeWriter;
#[derive(Clone, Default)]
pub(super) struct OAuthRecoveryLogCapture(Arc<Mutex<Vec<u8>>>);
static OAUTH_RECOVERY_LOG_CAPTURE_LOCK: AsyncMutex<()> = AsyncMutex::const_new(());
pub(super) struct OAuthRecoveryLogWriter(Arc<Mutex<Vec<u8>>>);
impl Write for OAuthRecoveryLogWriter {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.0
.lock()
.expect("OAuth recovery log buffer lock")
.extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl<'a> MakeWriter<'a> for OAuthRecoveryLogCapture {
type Writer = OAuthRecoveryLogWriter;
fn make_writer(&'a self) -> Self::Writer {
OAuthRecoveryLogWriter(Arc::clone(&self.0))
}
}
impl OAuthRecoveryLogCapture {
pub(super) fn dispatch(&self) -> tracing::Dispatch {
let subscriber = tracing_subscriber::fmt()
.json()
.with_target(true)
.without_time()
.with_writer(self.clone())
.finish();
tracing::Dispatch::new(subscriber)
}
pub(super) fn recovery_record(&self) -> Value {
let bytes = self
.0
.lock()
.expect("OAuth recovery log buffer lock")
.clone();
std::str::from_utf8(&bytes)
.expect("JSON log output is UTF-8")
.lines()
.map(|line| serde_json::from_str(line).expect("JSON log record"))
.find(|record: &Value| {
record["target"] == "openai_oauth_recovery"
&& record["fields"]["event"] == "openai_oauth_recovery"
})
.expect("OAuth recovery log record")
}
}
pub(super) async fn lock_oauth_recovery_log_capture() -> MutexGuard<'static, ()> {
OAUTH_RECOVERY_LOG_CAPTURE_LOCK.lock().await
}
mod admin;
mod agent_identity;
mod catalog;
mod contract;
mod cookie;
mod identity;
mod oauth;
mod quota;
mod refresh;
mod token_client;
mod types;
@@ -1,10 +1,5 @@
use std::{
sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
},
time::Duration,
};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
@@ -12,708 +7,205 @@ use gateway_admin::model::provider_credentials::{
AuthorizationMutationTarget, AuthorizationOwnerBinding, PendingAuthorizationMutation,
};
use gateway_admin::model::{MutationActor, MutationContext};
use gateway_core::engine::credential::ProviderAccountId;
use gateway_core::routing::ProviderKind;
use provider_openai::credential::token_client::{
AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant,
AuthorizationTokenSet,
};
use provider_openai::credential::{
CodexAuthenticatedAccount, CodexAuthenticatedAccountSource, CodexCredentialAdmin,
CodexIdentityExpectation, CodexIdentityVerificationError, CodexOAuthAdmin,
CodexOAuthAdminError, CodexOAuthAdminService, CodexOAuthPendingClaimOutcome,
CodexOAuthPendingStore, CodexOAuthPendingStoreError, CodexOAuthSecret,
CodexPendingAuthorization, CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential,
ImportCodexOAuthCredential, StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
CodexCredentialAdmin, CodexOAuthAdmin, CodexOAuthAdminError, CodexOAuthAdminService,
CodexOAuthPendingClaimOutcome, CodexOAuthPendingStore, CodexOAuthPendingStoreError,
CodexOAuthSecret, CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential,
StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
};
use secrecy::{ExposeSecret, SecretString};
use sha2::{Digest as _, Sha256};
use secrecy::SecretString;
use url::Url;
use super::{OAuthRecoveryLogCapture, lock_oauth_recovery_log_capture};
use crate::support::{MemoryAccountStore, profile, runtime_policy, secret};
use crate::support::{MemoryAccountStore, runtime_policy};
#[derive(Default)]
struct PendingStore {
value: Mutex<Option<(StoredCodexPendingAuthorization, Option<String>)>>,
}
fn duplicate_pending(
pending: &StoredCodexPendingAuthorization,
) -> Result<CodexPendingAuthorization, CodexOAuthPendingStoreError> {
CodexPendingAuthorization::from_stored(StoredCodexPendingAuthorization {
flow_id: pending.flow_id.clone(),
owner_ref: pending.owner_ref.clone(),
started_request_ref: pending.started_request_ref.clone(),
name: pending.name.clone(),
expires_at: pending.expires_at,
state: pending.state.clone(),
nonce: pending.nonce.clone(),
code_verifier: pending.code_verifier.clone(),
reauthorization_account_id: pending.reauthorization_account_id.clone(),
mutation: pending.mutation.clone(),
})
pending: Mutex<Option<StoredCodexPendingAuthorization>>,
}
#[async_trait]
impl CodexOAuthPendingStore for PendingStore {
async fn create(
&self,
pending: &CodexPendingAuthorization,
pending: &provider_openai::credential::CodexPendingAuthorization,
) -> Result<(), CodexOAuthPendingStoreError> {
let mut value = self.value.lock().expect("pending lock");
if value.is_some() {
let mut stored = self.pending.lock().expect("pending lock");
if stored.is_some() {
return Err(CodexOAuthPendingStoreError::Conflict);
}
*value = Some((
StoredCodexPendingAuthorization {
flow_id: pending.flow_id().to_owned(),
owner_ref: pending.owner_ref().to_owned(),
started_request_ref: pending.started_request_ref().to_owned(),
name: pending.name().to_owned(),
expires_at: pending.expires_at(),
state: pending.state().clone(),
nonce: pending.nonce().clone(),
code_verifier: pending.code_verifier().clone(),
reauthorization_account_id: pending.reauthorization().map(ToString::to_string),
mutation: pending.mutation().clone(),
},
None,
));
*stored = Some(StoredCodexPendingAuthorization {
flow_id: pending.flow_id().to_owned(),
owner_ref: pending.owner_ref().to_owned(),
started_request_ref: pending.started_request_ref().to_owned(),
name: pending.name().to_owned(),
expires_at: pending.expires_at(),
state: pending.state().clone(),
nonce: pending.nonce().clone(),
code_verifier: pending.code_verifier().clone(),
reauthorization_account_id: pending.reauthorization().map(ToString::to_string),
mutation: pending.mutation().clone(),
});
Ok(())
}
async fn claim(
&self,
owner_ref: &str,
flow_id: &str,
claim_ref: &str,
_claim_ttl: std::time::Duration,
_owner_ref: &str,
_flow_id: &str,
_claim_ref: &str,
_claim_ttl: Duration,
) -> Result<CodexOAuthPendingClaimOutcome, CodexOAuthPendingStoreError> {
let mut value = self.value.lock().expect("pending lock");
let Some((pending, claim)) = value.as_mut() else {
let Some(pending) = self.pending.lock().expect("pending lock").take() else {
return Ok(CodexOAuthPendingClaimOutcome::NotFound);
};
if pending.owner_ref != owner_ref || pending.flow_id != flow_id {
return Ok(CodexOAuthPendingClaimOutcome::NotFound);
}
if claim.is_some() {
return Ok(CodexOAuthPendingClaimOutcome::InProgress);
}
*claim = Some(claim_ref.to_owned());
duplicate_pending(pending)
.map(Box::new)
.map(CodexOAuthPendingClaimOutcome::Claimed)
let pending = provider_openai::credential::CodexPendingAuthorization::from_stored(pending)
.map_err(|_| CodexOAuthPendingStoreError::InvalidValue)?;
Ok(CodexOAuthPendingClaimOutcome::Claimed(Box::new(pending)))
}
async fn release_claim(
&self,
owner_ref: &str,
flow_id: &str,
claim_ref: &str,
_owner_ref: &str,
_flow_id: &str,
_claim_ref: &str,
) -> Result<bool, CodexOAuthPendingStoreError> {
let mut value = self.value.lock().expect("pending lock");
let Some((pending, claim)) = value.as_mut() else {
return Ok(false);
};
if pending.owner_ref != owner_ref
|| pending.flow_id != flow_id
|| claim.as_deref() != Some(claim_ref)
{
return Ok(false);
}
*claim = None;
Ok(true)
}
async fn consume_claim(
&self,
owner_ref: &str,
flow_id: &str,
claim_ref: &str,
_owner_ref: &str,
_flow_id: &str,
_claim_ref: &str,
) -> Result<bool, CodexOAuthPendingStoreError> {
let mut value = self.value.lock().expect("pending lock");
let Some((pending, claim)) = value.as_ref() else {
return Ok(false);
};
if pending.owner_ref != owner_ref
|| pending.flow_id != flow_id
|| claim.as_deref() != Some(claim_ref)
{
return Ok(false);
}
*value = None;
self.pending.lock().expect("pending lock").take();
Ok(true)
}
}
struct Exchanger;
struct Exchanger {
id_token: String,
}
#[async_trait]
impl AuthorizationCodeExchanger for Exchanger {
async fn exchange_authorization_code(
&self,
grant: AuthorizationCodeGrant,
_grant: AuthorizationCodeGrant,
) -> Result<AuthorizationTokenSet, AuthorizationCodeExchangeError> {
if grant.code.expose_secret() != "authorization-code" {
return Err(AuthorizationCodeExchangeError::Rejected);
}
Ok(AuthorizationTokenSet {
secret: CodexOAuthSecret {
access_token: SecretString::from("oauth-access-token"),
refresh_token: Some(SecretString::from("oauth-refresh-token")),
access_token: SecretString::from("access-token-from-upstream"),
refresh_token: Some(SecretString::from("refresh-token-from-upstream")),
id_token: None,
},
id_token: SecretString::from("oauth-id-token"),
expires_in: Duration::from_secs(3_600),
id_token: SecretString::from(self.id_token.clone()),
expires_in: Some(Duration::from_secs(60 * 60)),
})
}
}
struct NoRefreshTokenExchanger;
#[async_trait]
impl AuthorizationCodeExchanger for NoRefreshTokenExchanger {
async fn exchange_authorization_code(
&self,
grant: AuthorizationCodeGrant,
) -> Result<AuthorizationTokenSet, AuthorizationCodeExchangeError> {
if grant.code.expose_secret() != "authorization-code" {
return Err(AuthorizationCodeExchangeError::Rejected);
}
Ok(AuthorizationTokenSet {
secret: CodexOAuthSecret {
access_token: SecretString::from("oauth-access-without-refresh-token"),
refresh_token: None,
id_token: None,
},
id_token: SecretString::from("oauth-id-token"),
expires_in: Duration::from_secs(3_600),
})
}
}
#[derive(Default)]
struct StaticAccountSource {
calls: AtomicUsize,
}
impl StaticAccountSource {
fn call_count(&self) -> usize {
self.calls.load(Ordering::Relaxed)
}
}
#[async_trait]
impl CodexAuthenticatedAccountSource for StaticAccountSource {
async fn fetch(
&self,
_secret: &CodexOAuthSecret,
expectation: &CodexIdentityExpectation,
) -> Result<CodexAuthenticatedAccount, CodexIdentityVerificationError> {
self.calls.fetch_add(1, Ordering::Relaxed);
assert_eq!(expectation, &CodexIdentityExpectation::default());
Ok(CodexAuthenticatedAccount {
chatgpt_account_id: "chatgpt-oauth".to_owned(),
chatgpt_user_id: "user-chatgpt-oauth".to_owned(),
email: Some("oauth@example.com".to_owned()),
plan_type: Some("plus".to_owned()),
})
}
}
struct UnavailableAccountSource;
#[async_trait]
impl CodexAuthenticatedAccountSource for UnavailableAccountSource {
async fn fetch(
&self,
_secret: &CodexOAuthSecret,
_expectation: &CodexIdentityExpectation,
) -> Result<CodexAuthenticatedAccount, CodexIdentityVerificationError> {
Err(CodexIdentityVerificationError::Unavailable)
}
}
fn service() -> CodexOAuthAdminService {
service_with_store(Arc::new(MemoryAccountStore::default()))
}
fn service_with_store(store: Arc<MemoryAccountStore>) -> CodexOAuthAdminService {
service_with_account_source(store, Arc::new(StaticAccountSource::default()))
}
fn service_with_account_source(
store: Arc<MemoryAccountStore>,
account_source: Arc<dyn CodexAuthenticatedAccountSource>,
) -> CodexOAuthAdminService {
service_with_exchanger(store, account_source, Arc::new(Exchanger))
}
fn service_with_exchanger(
store: Arc<MemoryAccountStore>,
account_source: Arc<dyn CodexAuthenticatedAccountSource>,
exchanger: Arc<dyn AuthorizationCodeExchanger>,
) -> CodexOAuthAdminService {
CodexOAuthAdminService::new(
Arc::new(PendingStore::default()),
exchanger,
account_source,
store,
runtime_policy(),
CodexCredentialAdmin,
)
}
async fn started(
service: &CodexOAuthAdminService,
) -> provider_openai::credential::CodexOAuthAuthorizationStarted {
service
.start_authorization(StartCodexOAuthAuthorization {
mutation: create_mutation("request-one"),
})
.await
.expect("start OAuth")
}
fn owner_context(request_id: &str) -> MutationContext {
MutationContext {
actor: MutationActor::AdminSession {
admin_user_id: "owner-one".to_owned(),
},
request_id: request_id.to_owned(),
}
}
fn create_mutation(request_id: &str) -> PendingAuthorizationMutation {
let context = owner_context(request_id);
fn mutation() -> PendingAuthorizationMutation {
let context = MutationContext {
actor: MutationActor::System,
request_id: "oauth-alignment-test".to_owned(),
};
PendingAuthorizationMutation::new(
ProviderKind::new("openai").expect("provider"),
AuthorizationMutationTarget::Create {
name: "OAuth Account".to_owned(),
name: "new OAuth account".to_owned(),
},
AuthorizationOwnerBinding::from_context(&context),
)
}
fn reauthorization_mutation(request_id: &str, account_id: &str) -> PendingAuthorizationMutation {
let context = owner_context(request_id);
PendingAuthorizationMutation::new(
ProviderKind::new("openai").expect("provider"),
AuthorizationMutationTarget::Reauthorize {
account_id: ProviderAccountId::new(account_id).expect("account id"),
},
AuthorizationOwnerBinding::from_context(&context),
)
fn id_token(payload: serde_json::Value) -> String {
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload JSON"));
// 官方逻辑只读取 payload;header/signature 不参与本地 metadata 解析。
format!("unverified-header.{payload}.unverified-signature")
}
fn owner_ref() -> String {
let mut digest = Sha256::new();
digest.update(b"admin-session\0");
digest.update(b"owner-one");
URL_SAFE_NO_PAD.encode(digest.finalize())
}
#[tokio::test]
async fn start_authorization_uses_pkce_nonce_and_fixed_official_redirect() {
let service = service();
let started = started(&service).await;
let url = Url::parse(&started.authorization_url).expect("authorization URL");
let query = url
.query_pairs()
.collect::<std::collections::BTreeMap<_, _>>();
assert_eq!(
url.as_str().split('?').next(),
Some("https://auth.openai.com/oauth/authorize")
);
assert_eq!(
query.get("code_challenge_method").map(|v| v.as_ref()),
Some("S256")
);
assert_eq!(
query.get("redirect_uri").map(|v| v.as_ref()),
Some("http://localhost:1455/auth/callback")
);
assert!(query.get("state").is_some_and(|value| value.len() >= 16));
assert!(query.get("nonce").is_some_and(|value| value.len() >= 16));
}
#[tokio::test]
async fn completion_populates_create_profile_from_authenticated_account_source() {
let store = Arc::new(MemoryAccountStore::default());
let account_source = Arc::new(StaticAccountSource::default());
let service = service_with_account_source(store, account_source.clone());
let started = started(&service).await;
let url = Url::parse(&started.authorization_url).expect("authorization URL");
let state = url
.query_pairs()
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
.expect("state");
let prepared = service
.complete_authorization(CompleteCodexOAuthAuthorization {
owner_ref: owner_ref(),
flow_id: started.flow_id,
callback_url: SecretString::from(format!(
"http://localhost:1455/auth/callback?code=authorization-code&state={state}"
)),
})
.await
.expect("complete OAuth");
let CompletedCodexOAuthCredential::Create(prepared) = prepared.credential else {
panic!("expected prepared create");
};
assert_eq!(prepared.account.provider().as_str(), "openai");
assert!(prepared.account.id().as_str().starts_with("acct_"));
assert_eq!(
prepared.account.upstream_account_id(),
Some("chatgpt-oauth")
);
assert_eq!(prepared.account.upstream_user_id(), "user-chatgpt-oauth");
assert_eq!(prepared.account.email(), Some("oauth@example.com"));
assert_eq!(prepared.account.plan_type(), Some("plus"));
assert_eq!(
account_source.call_count(),
1,
"first OAuth authorization completes the account profile through the access-token source"
);
}
#[tokio::test]
async fn authorization_code_profile_failure_is_upstream_unavailable() {
let _capture_lock = lock_oauth_recovery_log_capture().await;
let service = service_with_account_source(
async fn complete(id_token: String) -> Result<CompletedCodexOAuthCredential, CodexOAuthAdminError> {
let service = CodexOAuthAdminService::new(
Arc::new(PendingStore::default()),
Arc::new(Exchanger { id_token }),
Arc::new(MemoryAccountStore::default()),
Arc::new(UnavailableAccountSource),
);
let started = started(&service).await;
let state = Url::parse(&started.authorization_url)
.expect("authorization URL")
.query_pairs()
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
.expect("state");
let recovery_log = OAuthRecoveryLogCapture::default();
let dispatch = recovery_log.dispatch();
let guard = tracing::dispatcher::set_default(&dispatch);
let error = service
.complete_authorization(CompleteCodexOAuthAuthorization {
owner_ref: owner_ref(),
flow_id: started.flow_id,
callback_url: SecretString::from(format!(
"http://localhost:1455/auth/callback?code=authorization-code&state={state}"
)),
})
.await
.expect_err("profile source fails after successful authorization-code exchange");
drop(guard);
assert_eq!(error, CodexOAuthAdminError::UpstreamUnavailable);
let record = recovery_log.recovery_record();
assert_eq!(record["fields"]["operation"], "authorization_code");
assert_eq!(record["fields"]["account_id"], "");
assert_eq!(record["fields"]["access_token"], "oauth-access-token");
assert_eq!(record["fields"]["refresh_token"], "oauth-refresh-token");
assert_eq!(record["fields"]["has_refresh_token"], true);
assert!(record["fields"].get("flow_id").is_none());
assert!(record["fields"].get("id_token").is_none());
}
#[tokio::test]
async fn completion_accepts_parseable_non_localhost_callback_url() {
let service = service();
let started = started(&service).await;
let state = Url::parse(&started.authorization_url)
.expect("authorization URL")
.query_pairs()
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
.expect("state");
let prepared = service
.complete_authorization(CompleteCodexOAuthAuthorization {
owner_ref: owner_ref(),
flow_id: started.flow_id,
callback_url: SecretString::from(format!(
"https://relay-next.aivify.cc/relay/openai/callback?code=authorization-code&state={state}&scope=openid%20profile&iss=https%3A%2F%2Fauth.openai.com"
)),
})
.await
.expect("complete OAuth from a relayed callback URL");
assert!(matches!(
prepared.credential,
CompletedCodexOAuthCredential::Create(_)
));
}
#[tokio::test]
async fn completion_rejects_repeated_code_callback_parameter() {
let service = service();
let started = started(&service).await;
let state = Url::parse(&started.authorization_url)
.expect("authorization URL")
.query_pairs()
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
.expect("state");
let error = service
.complete_authorization(CompleteCodexOAuthAuthorization {
owner_ref: owner_ref(),
flow_id: started.flow_id,
callback_url: SecretString::from(format!(
"http://localhost:1455/auth/callback?code=authorization-code&code=another-code&state={state}"
)),
})
.await
.expect_err("repeated code");
assert_eq!(error, CodexOAuthAdminError::CallbackRejected);
}
#[tokio::test]
async fn completion_rejects_repeated_state_callback_parameter() {
let service = service();
let started = started(&service).await;
let state = Url::parse(&started.authorization_url)
.expect("authorization URL")
.query_pairs()
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
.expect("state");
let error = service
.complete_authorization(CompleteCodexOAuthAuthorization {
owner_ref: owner_ref(),
flow_id: started.flow_id,
callback_url: SecretString::from(format!(
"http://localhost:1455/auth/callback?code=authorization-code&state={state}&state=another-state-value"
)),
})
.await
.expect_err("repeated state");
assert_eq!(error, CodexOAuthAdminError::CallbackRejected);
}
#[tokio::test]
async fn callback_state_mismatch_releases_flow_for_retry() {
let service = service();
let started = started(&service).await;
let flow_id = started.flow_id;
let command = || CompleteCodexOAuthAuthorization {
owner_ref: owner_ref(),
flow_id: flow_id.clone(),
callback_url: SecretString::from(
"http://localhost:1455/auth/callback?code=authorization-code&state=wrong-state-value",
),
};
assert_eq!(
service
.complete_authorization(command())
.await
.expect_err("bad state"),
CodexOAuthAdminError::CallbackRejected
);
assert_eq!(
service
.complete_authorization(command())
.await
.expect_err("retryable callback rejection"),
CodexOAuthAdminError::CallbackRejected
);
}
#[tokio::test]
async fn token_exchange_rejection_releases_flow_for_retry() {
let service = service();
let started = started(&service).await;
let flow_id = started.flow_id;
let state = Url::parse(&started.authorization_url)
.expect("authorization URL")
.query_pairs()
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
.expect("state");
let command = || CompleteCodexOAuthAuthorization {
owner_ref: owner_ref(),
flow_id: flow_id.clone(),
callback_url: SecretString::from(format!(
"http://localhost:1455/auth/callback?code=rejected-authorization-code&state={state}"
)),
};
assert_eq!(
service
.complete_authorization(command())
.await
.expect_err("token exchange rejection"),
CodexOAuthAdminError::TokenRejected
);
assert_eq!(
service
.complete_authorization(command())
.await
.expect_err("retryable token exchange rejection"),
CodexOAuthAdminError::TokenRejected
);
}
#[test]
fn oauth_command_debug_redacts_owner_flow_and_callback() {
let command = CompleteCodexOAuthAuthorization {
owner_ref: "owner-private".to_owned(),
flow_id: "flow-private".to_owned(),
callback_url: SecretString::from("callback-private-value"),
};
let debug = format!("{command:?}");
for secret in ["owner-private", "flow-private", "callback-private-value"] {
assert!(!debug.contains(secret));
}
}
#[tokio::test]
async fn reauthorization_preserves_existing_principal_without_identity_verification() {
let store = Arc::new(MemoryAccountStore::default());
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: "acct_oauth_reauth".to_owned(),
name: "reauthorize".to_owned(),
secret: secret("old-oauth-access"),
verified_account: profile("chatgpt-oauth"),
next_refresh_at: Some(chrono::Utc::now() + chrono::Duration::minutes(30)),
enabled: true,
})
.await;
let original_account = store
.account("acct_oauth_reauth")
.expect("original account");
let original_runtime = store
.repository()
.load_runtime_credential(&original_account)
.await
.expect("original credential");
let original_installation_id = original_runtime.installation_id;
let original_principal = original_runtime.principal;
let account_source = Arc::new(StaticAccountSource::default());
let service = service_with_account_source(store.clone(), account_source.clone());
let started = service
.start_authorization(StartCodexOAuthAuthorization {
mutation: reauthorization_mutation("request-reauth", "acct_oauth_reauth"),
})
.await
.expect("start reauthorization");
let advanced_revision = store.advance_credential_revision("acct_oauth_reauth").await;
assert_eq!(advanced_revision.get(), 2);
let state = Url::parse(&started.authorization_url)
.expect("authorization URL")
.query_pairs()
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
.expect("state");
let prepared = service
.complete_authorization(CompleteCodexOAuthAuthorization {
owner_ref: owner_ref(),
flow_id: started.flow_id,
callback_url: SecretString::from(format!(
"http://localhost:1455/auth/callback?code=authorization-code&state={state}"
)),
})
.await
.expect("complete reauthorization");
let CompletedCodexOAuthCredential::Reauthorize(prepared) = prepared.credential else {
panic!("expected prepared reauthorization");
};
assert_eq!(
prepared.credential.account_id().as_str(),
"acct_oauth_reauth"
);
assert_eq!(prepared.credential.expected_revision().get(), 2);
let runtime =
provider_openai::credential::CodexCredentialCodec::decode(prepared.credential.credential())
.expect("prepared credential");
assert_eq!(runtime.installation_id, original_installation_id);
assert_eq!(runtime.principal, original_principal);
assert!(prepared.replacement_identity.is_none());
assert_eq!(account_source.call_count(), 0);
assert_eq!(
store
.account("acct_oauth_reauth")
.expect("unchanged account")
.revision()
.get(),
2
);
}
#[tokio::test]
async fn reauthorization_preserves_existing_refresh_token_when_exchange_omits_one() {
let _capture_lock = lock_oauth_recovery_log_capture().await;
let store = Arc::new(MemoryAccountStore::default());
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: "acct_oauth_reauth_fallback".to_owned(),
name: "reauthorize fallback".to_owned(),
secret: secret("old-oauth-access"),
verified_account: profile("chatgpt-oauth"),
next_refresh_at: Some(chrono::Utc::now() + chrono::Duration::minutes(30)),
enabled: true,
})
.await;
let service = service_with_exchanger(
store,
Arc::new(StaticAccountSource::default()),
Arc::new(NoRefreshTokenExchanger),
runtime_policy(),
CodexCredentialAdmin,
);
let started = service
.start_authorization(StartCodexOAuthAuthorization {
mutation: reauthorization_mutation(
"request-reauth-fallback",
"acct_oauth_reauth_fallback",
),
mutation: mutation(),
})
.await
.expect("start reauthorization");
.expect("start OAuth authorization");
let state = Url::parse(&started.authorization_url)
.expect("authorization URL")
.query_pairs()
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
.expect("state");
let recovery_log = OAuthRecoveryLogCapture::default();
let dispatch = recovery_log.dispatch();
let guard = tracing::dispatcher::set_default(&dispatch);
let prepared = service
.expect("state parameter");
let completed = service
.complete_authorization(CompleteCodexOAuthAuthorization {
owner_ref: owner_ref(),
owner_ref: "test-owner".to_owned(),
flow_id: started.flow_id,
callback_url: SecretString::from(format!(
"http://localhost:1455/auth/callback?code=authorization-code&state={state}"
"http://localhost:1455/auth/callback?code=code-from-browser&state={state}"
)),
})
.await
.expect("complete reauthorization");
drop(guard);
let CompletedCodexOAuthCredential::Reauthorize(prepared) = prepared.credential else {
panic!("expected prepared reauthorization");
.await?;
Ok(completed.credential)
}
#[tokio::test]
async fn first_exchange_uses_official_id_token_claim_mapping_without_signature_validation() {
let credential = complete(id_token(serde_json::json!({
"email": "top@example.com",
"https://api.openai.com/profile": { "email": "fallback@example.com" },
"https://api.openai.com/auth": {
"chatgpt_plan_type": "pro",
"chatgpt_user_id": "chatgpt-user",
"user_id": "fallback-user",
"chatgpt_account_id": "workspace-id"
}
})))
.await
.expect("completed OAuth exchange");
let CompletedCodexOAuthCredential::Create(account) = credential else {
panic!("expected account creation");
};
let runtime =
provider_openai::credential::CodexCredentialCodec::decode(prepared.credential.credential())
.expect("prepared credential");
assert_eq!(
runtime
.authentication
.oauth()
.expect("OAuth credential")
.refresh_token
.as_ref()
.expect("preserved refresh token")
.expose_secret(),
"rt-old-oauth-access"
assert_eq!(account.account.email(), Some("top@example.com"));
assert_eq!(account.account.upstream_user_id(), Some("chatgpt-user"));
assert_eq!(account.account.upstream_account_id(), Some("workspace-id"));
assert_eq!(account.account.plan_type(), Some("pro"));
assert!(account.account.is_schedulable(SystemTime::now()));
assert!(
account.account.next_refresh_at() > Some(SystemTime::now() + Duration::from_secs(30 * 60)),
"a one-hour first AT must not immediately trigger RT refresh"
);
let record = recovery_log.recovery_record();
assert_eq!(record["fields"]["operation"], "authorization_code");
assert_eq!(record["fields"]["account_id"], "acct_oauth_reauth_fallback");
assert_eq!(
record["fields"]["access_token"],
"oauth-access-without-refresh-token"
);
assert_eq!(record["fields"]["refresh_token"], "rt-old-oauth-access");
assert_eq!(record["fields"]["has_refresh_token"], true);
}
#[tokio::test]
async fn first_exchange_permits_missing_identity_claims_but_requires_a_parseable_id_token() {
let credential = complete(id_token(serde_json::json!({
"https://api.openai.com/profile": { "email": "profile@example.com" }
})))
.await
.expect("missing optional claims are allowed");
let CompletedCodexOAuthCredential::Create(account) = credential else {
panic!("expected account creation");
};
assert_eq!(account.account.email(), Some("profile@example.com"));
assert_eq!(account.account.upstream_user_id(), None);
assert!(!account.account.is_schedulable(SystemTime::now()));
let error = complete("header.not-base64.signature".to_owned())
.await
.expect_err("official local payload parsing rejects malformed id tokens");
assert_eq!(error, CodexOAuthAdminError::TokenRejected);
}
@@ -1106,7 +1106,7 @@ async fn periodic_quota_synchronization_respects_retry_after_on_service_unavaila
}
#[tokio::test]
async fn periodic_quota_synchronization_skips_ready_accounts_without_quota_reached() {
async fn initial_quota_synchronization_retries_ready_accounts_without_observation() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_periodic_temporary_cooldown").await;
let account = store
@@ -1126,9 +1126,9 @@ async fn periodic_quota_synchronization_skips_ready_accounts_without_quota_reach
let summary = blocked_network_quota_service(&store)
.synchronize()
.await
.expect("ordinary cooldown must not query upstream");
.expect("initial quota sync must not fail the worker");
assert_eq!(summary, CodexQuotaSyncSummary::default());
assert_eq!(summary.transient, 1);
assert_eq!(
store
.account("acct_periodic_temporary_cooldown")
File diff suppressed because it is too large Load Diff
@@ -72,11 +72,11 @@ async fn bounded_oauth_response_should_parse_lifetime_and_rotated_token() {
assert_eq!(tokens.access_token, "access-rotated");
assert_eq!(tokens.refresh_token.as_deref(), Some("refresh-rotated"));
assert_eq!(tokens.expires_in, Duration::from_secs(3600));
assert_eq!(tokens.expires_in, Some(Duration::from_secs(3600)));
}
#[tokio::test]
async fn refresh_response_with_blank_rotated_token_keeps_the_existing_token_eligible() {
async fn refresh_response_keeps_the_upstream_rotated_token_verbatim() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
@@ -92,9 +92,9 @@ async fn refresh_response_with_blank_rotated_token_keeps_the_existing_token_elig
let tokens = client(&server)
.refresh("refresh-initial")
.await
.expect("blank rotation field is treated as absent");
.expect("upstream response is accepted");
assert_eq!(tokens.refresh_token, None);
assert_eq!(tokens.refresh_token.as_deref(), Some(" "));
}
#[tokio::test]
@@ -162,7 +162,41 @@ async fn authorization_code_exchange_should_require_bounded_oidc_token_set_and_p
}
#[tokio::test]
async fn authorization_code_exchange_should_reject_missing_nonce_carrier_or_non_json_success() {
async fn authorization_code_exchange_keeps_upstream_access_and_refresh_tokens_verbatim() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": " ",
"refresh_token": "",
"id_token": "header.payload.signature"
})))
.expect(1)
.mount(&server)
.await;
let tokens = client(&server)
.exchange_authorization_code(AuthorizationCodeGrant {
code: SecretString::from("authorization-code"),
code_verifier: SecretString::from("pkce-verifier"),
})
.await
.expect("token endpoint response is trusted structurally");
assert_eq!(tokens.secret.access_token.expose_secret(), " ");
assert_eq!(
tokens
.secret
.refresh_token
.as_ref()
.expect("required response field")
.expose_secret(),
""
);
}
#[tokio::test]
async fn authorization_code_exchange_requires_id_token_and_json_response() {
let missing_id = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
@@ -40,10 +40,10 @@ fn account_profile_debug_redacts_identity_fields() {
fn plaintext_provider_schema_round_trips_dynamic_cookie_data() {
let data = CodexCredentialData::OAuth(CodexOAuthCredentialData {
schema_version: 1,
principal: CodexCredentialPrincipal {
principal: Some(CodexCredentialPrincipal {
oauth_subject: "subject-private".to_owned(),
poid: Some("poid-private".to_owned()),
},
}),
installation_id: "00000000-0000-4000-8000-000000000001".to_owned(),
access_token: "at".to_owned(),
refresh_token: Some("rt".to_owned()),
@@ -80,39 +80,6 @@ impl MemoryAccountStore {
.and_then(|stored| stored.last_error_message.clone())
}
pub(crate) async fn advance_credential_revision(&self, id: &str) -> CredentialRevision {
let account_id = ProviderAccountId::new(id).expect("test account id");
let current = self
.load_current_credential(&account_id)
.await
.expect("current test credential");
let account = current.account;
let expected_revision = account.revision();
let update = CredentialCasUpdate::new(
account_id.clone(),
expected_revision,
ProviderAccountUpdate {
account_id,
name: account.name().to_owned(),
email: account.email().map(str::to_owned),
plan_type: account.plan_type().map(str::to_owned),
},
current.credential,
account.has_refresh_token(),
account.access_token_expires_at(),
account.next_refresh_at(),
)
.expect("valid test credential update");
match self
.compare_and_swap_credential(update)
.await
.expect("advance test credential revision")
{
CredentialCasOutcome::Updated(revision) => revision,
CredentialCasOutcome::Conflict => panic!("test credential revision conflicted"),
}
}
pub(crate) fn quota_reads(&self) -> usize {
self.quota_reads.load(Ordering::SeqCst)
}
@@ -420,7 +387,7 @@ fn rebuild_account(current: &ProviderAccount, rebuild: AccountRebuild) -> Provid
current.id().clone(),
current.provider().clone(),
name,
current.upstream_user_id().to_owned(),
current.upstream_user_id().map(str::to_owned),
current.authentication_kind().to_owned(),
rebuild.revision,
rebuild.access_token_expires_at,
@@ -770,25 +737,6 @@ pub(crate) fn account_policy() -> gateway_core::engine::credential::AccountSelec
)
}
pub(crate) fn codex_account(id: &str) -> ProviderAccount {
ProviderAccount::new(
ProviderAccountId::new(id).expect("account id"),
ProviderKind::new("openai").expect("provider"),
id.to_owned(),
format!("user-chatgpt-{id}"),
"oauth".to_owned(),
CredentialRevision::new(1).expect("revision"),
Some(SystemTime::now() + Duration::from_secs(3_600)),
)
.with_profile(
Some(format!("{id}@example.com")),
Some(format!("chatgpt-{id}")),
Some("pro".to_owned()),
)
.with_runtime_state(true, AccountAvailability::Ready)
.with_refresh_schedule(true, Some(SystemTime::now() + Duration::from_secs(2_700)))
}
/// 内存 `ProviderCooldownPort`:只实现 `read`/`put_if_later`(openai selector
/// 429 冷却路径用),其余 scope 变体测试不涉及,返回占位。
#[derive(Clone, Default)]
+2 -2
View File
@@ -909,7 +909,7 @@ fn prepared_create(
provider_kind: account.provider().clone(),
name: account.name().to_owned(),
email: account.email().map(str::to_owned),
upstream_user_id: account.upstream_user_id().to_owned(),
upstream_user_id: account.upstream_user_id().map(str::to_owned),
upstream_account_id: account.upstream_account_id().map(str::to_owned),
plan_type: account.plan_type().map(str::to_owned),
authentication_kind: account.authentication_kind().to_owned(),
@@ -1032,7 +1032,7 @@ fn account_from_record(account: &AccountRecord) -> Result<ProviderAccount, Provi
fn account_matches_record(account: &ProviderAccount, record: &AccountRecord) -> bool {
account.id().as_str() == record.id
&& account.provider() == &record.provider_kind
&& account.upstream_user_id() == record.upstream_user_id
&& account.upstream_user_id() == record.upstream_user_id.as_deref()
&& account.upstream_account_id() == record.upstream_account_id.as_deref()
&& account.authentication_kind() == record.authentication_kind
}
@@ -1166,9 +1166,13 @@ async fn fetch_candidate_catalog(
candidate: LoadedGrokCredential,
wire_profile: XaiWireProfileState,
) -> Result<FetchedCredentialCatalog, GrokCredentialCatalogError> {
let upstream_user_id = candidate
.account
.upstream_user_id()
.ok_or(GrokCredentialCatalogError::InvalidCredentialData)?;
let session = GrokModelCatalogSession::new(
candidate.access_token,
SecretValue::new(candidate.account.upstream_user_id().to_owned()),
SecretValue::new(upstream_user_id),
candidate
.account
.email()
@@ -1214,9 +1218,13 @@ fn billing_session(
loaded: &LoadedGrokCredential,
wire_profile: &XaiWireProfileState,
) -> Result<GrokModelCatalogSession, GrokQuotaError> {
let upstream_user_id = loaded
.account
.upstream_user_id()
.ok_or(GrokQuotaError::InvalidData)?;
GrokModelCatalogSession::new(
loaded.access_token.clone(),
SecretValue::new(loaded.account.upstream_user_id().to_owned()),
SecretValue::new(upstream_user_id),
loaded
.account
.email()
@@ -363,6 +363,11 @@ impl GrokCredentialRefreshService {
.await
.map_err(|_| GrokCredentialRefreshError::Preparation)?;
let policy = self.runtime_policy.load_refresh_policy().await?;
let subject = loaded
.account
.upstream_user_id()
.ok_or(GrokCredentialRepositoryError::InvalidCredentialData)?
.to_owned();
let credential = DueGrokCredential {
account_id: account_id.clone(),
credential_revision,
@@ -370,7 +375,7 @@ impl GrokCredentialRefreshService {
refresh_token: loaded.refresh_token,
id_token: loaded.id_token,
scope: loaded.scope,
subject: loaded.account.upstream_user_id().to_owned(),
subject,
email: loaded.account.email().map(str::to_owned),
upstream_account_id: loaded.account.upstream_account_id().map(str::to_owned),
plan_type: loaded.account.plan_type().map(str::to_owned),
@@ -442,6 +447,11 @@ impl GrokCredentialRefreshService {
let (access_token_expires_at, next_refresh_at) =
refreshed_deadlines(&account_id, tokens.expires_in, policy)
.ok_or(GrokCredentialRefreshError::InvalidRefreshResponse)?;
let subject = loaded
.account
.upstream_user_id()
.ok_or(GrokCredentialRepositoryError::InvalidCredentialData)?
.to_owned();
let prepared = GrokCredentialAdmin
.prepare_rotation(&RotateManagedGrokCredential {
current,
@@ -452,7 +462,7 @@ impl GrokCredentialRefreshService {
scope: loaded.scope,
},
verified_account: GrokAccountProfile {
subject: loaded.account.upstream_user_id().to_owned(),
subject,
email: loaded.account.email().map(str::to_owned),
upstream_account_id: loaded.account.upstream_account_id().map(str::to_owned),
plan_type: loaded.account.plan_type().map(str::to_owned),
@@ -880,6 +890,10 @@ impl GrokCredentialRepository {
continue;
}
};
let Some(subject) = account.upstream_user_id() else {
failed_account_ids.push(account.id().clone());
continue;
};
due.push(DueGrokCredential {
account_id: account.id().clone(),
credential_revision: account.revision(),
@@ -887,7 +901,7 @@ impl GrokCredentialRepository {
refresh_token: loaded.refresh_token,
id_token: loaded.id_token,
scope: loaded.scope,
subject: account.upstream_user_id().to_owned(),
subject: subject.to_owned(),
email: account.email().map(str::to_owned),
upstream_account_id: account.upstream_account_id().map(str::to_owned),
plan_type: account.plan_type().map(str::to_owned),
@@ -158,7 +158,7 @@ impl GrokCredentialAdmin {
input.account_id.clone(),
provider,
input.name.clone(),
input.account.subject.clone(),
Some(input.account.subject.clone()),
XAI_AUTHENTICATION_KIND_OAUTH.to_owned(),
revision,
Some(to_system_time(input.account.access_token_expires_at)),
@@ -653,7 +653,7 @@ fn prepare_rotation(
if current.revision() != expected_revision {
return Err(GrokCredentialRepositoryError::StaleCredentialRevision);
}
if current.upstream_user_id() != verified_account.subject
if current.upstream_user_id() != Some(verified_account.subject.as_str())
|| current.upstream_account_id() != verified_account.upstream_account_id.as_deref()
{
return Err(GrokCredentialRepositoryError::IdentityRebind);
@@ -819,6 +819,7 @@ fn validate_reason(reason: Option<&str>) -> Result<(), GrokCredentialRepositoryE
fn ensure_xai(account: &ProviderAccount) -> Result<(), GrokCredentialRepositoryError> {
if account.provider().as_str() == XAI_PROVIDER_KIND
&& account.authentication_kind() == XAI_AUTHENTICATION_KIND_OAUTH
&& account.upstream_user_id().is_some()
&& account.access_token_expires_at().is_some()
{
Ok(())
@@ -228,11 +228,15 @@ impl GrokAccountSessionSelector {
}
let binding = GrokSessionBinding::new(selected_id.as_str())
.map_err(|_| GrokSessionSelectorError::InvalidSession)?;
let upstream_user_id = loaded
.account
.upstream_user_id()
.ok_or(GrokSessionSelectorError::InvalidSession)?;
let session = SelectedGrokSession::new(
selected_id,
selected_revision,
loaded.access_token,
SecretValue::new(loaded.account.upstream_user_id().to_owned()),
SecretValue::new(upstream_user_id),
loaded
.account
.email()
+1 -1
View File
@@ -462,7 +462,7 @@ fn account_record(account: &ProviderAccount) -> AccountRecord {
provider_kind: account.provider().clone(),
name: account.name().to_owned(),
email: account.email().map(str::to_owned),
upstream_user_id: account.upstream_user_id().to_owned(),
upstream_user_id: account.upstream_user_id().map(str::to_owned),
upstream_account_id: account.upstream_account_id().map(str::to_owned),
plan_type: account.plan_type().map(str::to_owned),
authentication_kind: account.authentication_kind().to_owned(),
@@ -63,7 +63,7 @@ async fn create_projects_identity_and_revision_to_common_columns() {
.expect("create account");
let account = store.account(&input.account_id).expect("account");
assert_eq!(account.upstream_user_id(), "subject-projection");
assert_eq!(account.upstream_user_id(), Some("subject-projection"));
assert_eq!(account.email(), Some("subject-projection@example.com"));
assert!(account.has_refresh_token());
}
@@ -276,7 +276,7 @@ async fn repository_rejects_account_owned_by_another_provider() {
id.clone(),
ProviderKind::new("openai").expect("provider"),
"other".to_owned(),
"subject".to_owned(),
Some("subject".to_owned()),
"oauth".to_owned(),
revision,
Some(std::time::SystemTime::now() + std::time::Duration::from_secs(3600)),
@@ -471,7 +471,7 @@ fn rebuild_account(previous: &ProviderAccount, replacement: AccountReplacement)
previous.id().clone(),
previous.provider().clone(),
replacement.name,
previous.upstream_user_id().to_owned(),
previous.upstream_user_id().map(str::to_owned),
previous.authentication_kind().to_owned(),
replacement.revision,
replacement.access_token_expires_at,
+1
View File
@@ -1 +1,2 @@
5f79e74c9a7a7366c39cd1afe43175eff6c44afe9217c0f26f33f173bd40c781 0001_initial.sql
f4168f7e23d42cf9d37096339e931b0e0c823f7a8dbfc6580a04f096c4c3d8e3 0002_provider_accounts_nullable_upstream_user_id.sql
@@ -0,0 +1,3 @@
-- Initial OAuth exchange requires an id_token, but its ChatGPT user claim is optional.
alter table provider_accounts
alter column upstream_user_id drop not null;
+6 -15
View File
@@ -70,7 +70,7 @@ const loadingIconSize: Record<ButtonSize, number> = {
}
const classes = computed(() => [
'relative inline-flex items-center justify-center border-0 font-bold leading-[1.15] transition-[background-color,box-shadow,color,opacity,transform] duration-150 cursor-pointer outline-none motion-safe:active:translate-y-px motion-safe:active:scale-[0.985]',
'inline-flex items-center justify-center border-0 font-bold leading-[1.15] transition-[background-color,box-shadow,color,opacity,transform] duration-150 cursor-pointer outline-none motion-safe:active:translate-y-px motion-safe:active:scale-[0.985]',
props.iconOnly ? iconOnlySizeClasses[props.size] : sizeClasses[props.size],
props.iconOnly ? '' : 'rounded-(--cp-button-radius-base)',
'focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)',
@@ -110,7 +110,7 @@ const labelClasses = computed(() => [
>
<span
v-if="loading"
class="pointer-events-none absolute inset-0 inline-flex items-center justify-center leading-none"
class="inline-flex shrink-0 items-center justify-center leading-none"
aria-hidden="true"
>
<LoaderCircle
@@ -118,24 +118,15 @@ const labelClasses = computed(() => [
class="block animate-spin origin-center [transform-box:view-box] will-change-transform"
/>
</span>
<span
v-if="$slots.icon"
class="inline-flex shrink-0 transition-opacity duration-150"
:class="loading ? 'opacity-0' : undefined"
>
<span v-if="$slots.icon && !loading" class="inline-flex shrink-0">
<slot name="icon" />
</span>
<span
v-if="!iconOnly && $slots.default"
class="transition-opacity duration-150"
:class="[labelClasses, loading ? 'opacity-0' : undefined]"
>
<span v-if="!iconOnly && $slots.default" :class="labelClasses">
<slot />
</span>
<span
v-if="iconOnly && !$slots.icon"
class="inline-flex items-center justify-center transition-opacity duration-150"
:class="loading ? 'opacity-0' : undefined"
v-if="iconOnly && !$slots.icon && !loading"
class="inline-flex items-center justify-center"
>
<slot />
</span>
+1 -1
View File
@@ -1 +1 @@
version: "3.1.13"
version: "3.1.15"