mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-12 00:06:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c22a2ad144 | ||
|
|
7bd362d544 | ||
|
|
e7036a616f | ||
|
|
98652d4c68 | ||
|
|
d0dc7ef92b | ||
|
|
194fb20d5f | ||
|
|
30f528f084 | ||
|
|
28d024ceda | ||
|
|
6b9c98c59f |
Generated
+1
@@ -2628,6 +2628,7 @@ dependencies = [
|
||||
"tokio-tungstenite 0.28.0",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tungstenite 0.27.0",
|
||||
"url",
|
||||
"uuid",
|
||||
|
||||
@@ -13,6 +13,8 @@ use tracing_subscriber::{Layer as _, layer::SubscriberExt as _, util::Subscriber
|
||||
use crate::config::LoggingConfig;
|
||||
|
||||
const LOG_FILE_PREFIX: &str = "codex-proxy-rs";
|
||||
/// OpenAI OAuth 恢复记录含原始 AT/RT,文件日志开启时不能被常规级别过滤掉。
|
||||
const OPENAI_OAUTH_RECOVERY_LOG_TARGET: &str = "openai_oauth_recovery";
|
||||
|
||||
/// non-blocking 日志 writer 的进程级守卫。
|
||||
pub struct LogGuard {
|
||||
@@ -34,7 +36,7 @@ pub enum LogError {
|
||||
/// 按自然日、单文件大小、保留天数和文件总数初始化结构化日志。
|
||||
pub fn initialize_logging(config: &LoggingConfig) -> Result<LogGuard, LogError> {
|
||||
let directive = env::var("RUST_LOG").unwrap_or_else(|_| config.level.clone());
|
||||
let file_filter = EnvFilter::try_new(&directive).map_err(|_| LogError::InvalidFilter)?;
|
||||
let file_filter = file_filter(&directive)?;
|
||||
let stdout_filter =
|
||||
EnvFilter::try_new(stdout_filter_directive(&directive, config.file.enabled))
|
||||
.map_err(|_| LogError::InvalidFilter)?;
|
||||
@@ -97,6 +99,15 @@ pub fn initialize_logging(config: &LoggingConfig) -> Result<LogGuard, LogError>
|
||||
Ok(LogGuard { _writers: guards })
|
||||
}
|
||||
|
||||
fn file_filter(directive: &str) -> Result<EnvFilter, LogError> {
|
||||
let recovery_directive = format!("{OPENAI_OAUTH_RECOVERY_LOG_TARGET}=info")
|
||||
.parse()
|
||||
.expect("static OpenAI OAuth recovery log directive is valid");
|
||||
EnvFilter::try_new(directive)
|
||||
.map(|filter| filter.add_directive(recovery_directive))
|
||||
.map_err(|_| LogError::InvalidFilter)
|
||||
}
|
||||
|
||||
fn stdout_filter_directive(directive: &str, persistent_log_enabled: bool) -> String {
|
||||
if !persistent_log_enabled {
|
||||
directive.to_owned()
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
use std::path::PathBuf;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use gateway_host::config::{FileLoggingConfig, HostConfig, ListenConfig, LoggingConfig};
|
||||
use gateway_host::system_update::SystemUpdateConfig;
|
||||
|
||||
const LOG_DIRECTORY_ENV: &str = "CPR_LOGGING_TEST_DIRECTORY";
|
||||
const CHILD_PROCESS_ENV: &str = "CPR_LOGGING_TEST_CHILD";
|
||||
const OAUTH_RECOVERY_LOG_TARGET: &str = "openai_oauth_recovery";
|
||||
const OAUTH_RECOVERY_LOG_MARKER: &str = "oauth-recovery-file-filter-test";
|
||||
|
||||
#[test]
|
||||
fn logging_requires_at_least_one_sink() {
|
||||
let mut config = HostConfig {
|
||||
@@ -32,3 +40,87 @@ fn logging_requires_at_least_one_sink() {
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_recovery_file_logging_overrides_global_log_level() {
|
||||
if env::var_os(CHILD_PROCESS_ENV).is_some() {
|
||||
write_oauth_recovery_log(PathBuf::from(
|
||||
env::var_os(LOG_DIRECTORY_ENV).expect("child log directory"),
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
let directory = tempfile::tempdir().expect("create log directory");
|
||||
let output = Command::new(env::current_exe().expect("current test executable"))
|
||||
.args([
|
||||
"--exact",
|
||||
"logging::oauth_recovery_file_logging_overrides_global_log_level",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD_PROCESS_ENV, "1")
|
||||
.env(LOG_DIRECTORY_ENV, directory.path())
|
||||
.env("RUST_LOG", "off,openai_oauth_recovery=off")
|
||||
.output()
|
||||
.expect("run logging child process");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"logging child process failed:\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
|
||||
let log = read_log_directory(directory.path());
|
||||
assert!(log.contains(OAUTH_RECOVERY_LOG_TARGET));
|
||||
assert!(log.contains(OAUTH_RECOVERY_LOG_MARKER));
|
||||
}
|
||||
|
||||
fn write_oauth_recovery_log(directory: PathBuf) {
|
||||
let config = HostConfig {
|
||||
listen: ListenConfig {
|
||||
host: "127.0.0.1".to_owned(),
|
||||
port: 8080,
|
||||
},
|
||||
logging: LoggingConfig {
|
||||
level: "off".to_owned(),
|
||||
stdout: false,
|
||||
file: FileLoggingConfig {
|
||||
enabled: true,
|
||||
directory,
|
||||
retention_days: 1,
|
||||
max_file_size_mb: 1,
|
||||
max_files: 1,
|
||||
},
|
||||
},
|
||||
system_update: SystemUpdateConfig::default(),
|
||||
drain_timeout_seconds: 30,
|
||||
worker_shutdown_timeout_seconds: 30,
|
||||
};
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("create logging runtime");
|
||||
let bundle = runtime
|
||||
.block_on(gateway_host::initialize(config))
|
||||
.expect("initialize logging");
|
||||
|
||||
tracing::info!(
|
||||
target: OAUTH_RECOVERY_LOG_TARGET,
|
||||
marker = OAUTH_RECOVERY_LOG_MARKER,
|
||||
"OpenAI OAuth recovery test record"
|
||||
);
|
||||
drop(bundle);
|
||||
}
|
||||
|
||||
fn read_log_directory(directory: &Path) -> String {
|
||||
let mut files = fs::read_dir(directory)
|
||||
.expect("read log directory")
|
||||
.map(|entry| entry.expect("read log entry").path())
|
||||
.collect::<Vec<_>>();
|
||||
files.sort();
|
||||
|
||||
files
|
||||
.into_iter()
|
||||
.map(|path| fs::read_to_string(path).expect("read log file"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ zstd = "0.13"
|
||||
[dev-dependencies]
|
||||
tempfile = "3.27.0"
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["rt-multi-thread", "test-util"] }
|
||||
tracing-subscriber = { version = "0.3.23", features = ["json"] }
|
||||
wiremock = "0.6.5"
|
||||
|
||||
[[test]]
|
||||
|
||||
@@ -1381,14 +1381,15 @@ fn map_credential_admin_error(error: CodexCredentialAdminError) -> ProviderAdmin
|
||||
| Error::MissingRefreshToken
|
||||
| Error::RefreshRejected
|
||||
| Error::AccountBanned
|
||||
| Error::AccountProfileRejected
|
||||
| Error::IdentityRejected => ProviderAdminErrorKind::Invalid,
|
||||
Error::NotFound => ProviderAdminErrorKind::NotFound,
|
||||
Error::RefreshLeaseUnavailable | Error::RefreshAmbiguous => {
|
||||
ProviderAdminErrorKind::Conflict
|
||||
}
|
||||
Error::RefreshUnavailable | Error::IdentityUnavailable => {
|
||||
ProviderAdminErrorKind::Unavailable
|
||||
}
|
||||
Error::RefreshUnavailable
|
||||
| Error::AccountProfileUnavailable
|
||||
| Error::IdentityUnavailable => ProviderAdminErrorKind::Unavailable,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1402,6 +1403,8 @@ const fn credential_admin_error_code(error: CodexCredentialAdminError) -> &'stat
|
||||
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",
|
||||
|
||||
@@ -43,10 +43,8 @@ impl OpenAiConfig {
|
||||
self.quota.validate()?;
|
||||
self.auth.validate()?;
|
||||
self.wire_profile.validate()?;
|
||||
self.identity_secret_path = source_dir
|
||||
.parent()
|
||||
.unwrap_or(source_dir)
|
||||
.join(".runtime/data/identity_hmac_secret");
|
||||
let project_root = source_dir.parent().unwrap_or(source_dir);
|
||||
self.identity_secret_path = project_root.join(".runtime/data/identity_hmac_secret");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -26,8 +26,9 @@ use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
use super::identity::{
|
||||
CodexAccountIdentityVerifier, CodexIdentityExpectation, CodexIdentityVerificationError,
|
||||
CodexAuthenticatedAccountSource, CodexIdentityExpectation, CodexIdentityVerificationError,
|
||||
};
|
||||
use super::recovery_log::{CodexOAuthRecoveryOperation, record_oauth_recovery};
|
||||
use super::security::CodexCredentialCodec;
|
||||
use super::token_client::{RefreshFailure, TokenRefresher};
|
||||
use super::types::{
|
||||
@@ -40,6 +41,8 @@ const PROVIDER_NAME: &str = "openai";
|
||||
const CODEX_CREDENTIAL_SCHEMA_VERSION: u32 = 1;
|
||||
const MAX_BATCH: usize = 200;
|
||||
const MAX_IMPORT_DOCUMENT_BYTES: usize = 64 * 1024 * 1024;
|
||||
const MAX_IMPORT_EMAIL_BYTES: usize = 512;
|
||||
const MAX_IMPORT_PLAN_BYTES: usize = 128;
|
||||
|
||||
pub struct ImportCodexOAuthCredential {
|
||||
pub account_id: String,
|
||||
@@ -365,6 +368,10 @@ 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")]
|
||||
@@ -387,8 +394,7 @@ impl CodexCredentialAdmin {
|
||||
if input.name.trim().is_empty() {
|
||||
return Err(CodexCredentialAdminError::InvalidInput);
|
||||
}
|
||||
let access_token_expires_at =
|
||||
required_time(input.verified_account.access_token_expires_at)?;
|
||||
let access_token_expires_at = optional_time(input.verified_account.access_token_expires_at);
|
||||
let revision =
|
||||
CredentialRevision::new(1).map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
|
||||
let upstream_user_id = input.verified_account.chatgpt_user_id.clone();
|
||||
@@ -402,7 +408,7 @@ impl CodexCredentialAdmin {
|
||||
upstream_user_id,
|
||||
CODEX_AUTHENTICATION_KIND_OAUTH.to_owned(),
|
||||
revision,
|
||||
Some(access_token_expires_at),
|
||||
access_token_expires_at,
|
||||
)
|
||||
.with_profile(
|
||||
input.verified_account.email,
|
||||
@@ -499,12 +505,60 @@ impl CodexCredentialAdmin {
|
||||
self.prepare_oauth_rotation(input, false)
|
||||
}
|
||||
|
||||
/// 完整 OAuth 授权按目标数据 ID 替换凭据与上游账号身份。
|
||||
pub(crate) fn prepare_reauthorization(
|
||||
/// 构建一次成功 RT exchange 的 CAS 写入,保留已存账号身份资料。
|
||||
///
|
||||
/// Refresh endpoint 已经完成本次 token 交换的授权;这里不对新 access
|
||||
/// token 重新执行身份验证。
|
||||
pub(crate) fn prepare_refreshed_oauth_rotation(
|
||||
&self,
|
||||
input: RotateManagedCodexCredential,
|
||||
current: LoadedCredential,
|
||||
secret: CodexOAuthSecret,
|
||||
access_token_expires_at: DateTime<Utc>,
|
||||
next_refresh_at: Option<DateTime<Utc>>,
|
||||
) -> Result<PreparedCodexCredentialRotation, CodexCredentialAdminError> {
|
||||
self.prepare_oauth_rotation(input, true)
|
||||
if current.account.provider().as_str() != PROVIDER_NAME
|
||||
|| current.account.authentication_kind() != CODEX_AUTHENTICATION_KIND_OAUTH
|
||||
{
|
||||
return Err(CodexCredentialAdminError::IdentityMismatch);
|
||||
}
|
||||
let mut data = CodexCredentialCodec::decode_complete(¤t.credential)
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
|
||||
let oauth = data
|
||||
.oauth_mut()
|
||||
.ok_or(CodexCredentialAdminError::IdentityMismatch)?;
|
||||
oauth.access_token = secret.access_token.expose_secret().to_owned();
|
||||
oauth.refresh_token = secret
|
||||
.refresh_token
|
||||
.as_ref()
|
||||
.map(|value| value.expose_secret().to_owned());
|
||||
oauth.id_token = secret
|
||||
.id_token
|
||||
.as_ref()
|
||||
.map(|value| value.expose_secret().to_owned());
|
||||
let credential = CodexCredentialCodec::encode_complete(data)
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
|
||||
let profile = ProviderAccountUpdate {
|
||||
account_id: current.account.id().clone(),
|
||||
name: current.account.name().to_owned(),
|
||||
email: current.account.email().map(str::to_owned),
|
||||
plan_type: current.account.plan_type().map(str::to_owned),
|
||||
};
|
||||
let credential = CredentialCasUpdate::new(
|
||||
current.account.id().clone(),
|
||||
current.account.revision(),
|
||||
profile.clone(),
|
||||
credential,
|
||||
secret.refresh_token.is_some(),
|
||||
Some(SystemTime::from(access_token_expires_at)),
|
||||
optional_time(next_refresh_at),
|
||||
)
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
|
||||
Ok(PreparedCodexCredentialRotation {
|
||||
profile,
|
||||
credential,
|
||||
replacement_identity: None,
|
||||
refresh_guards: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn prepare_oauth_rotation(
|
||||
@@ -588,7 +642,7 @@ impl CodexCredentialAdmin {
|
||||
/// 有状态的 Codex 手工刷新边界;消费调用方刚读取的当前 credential 并准备 CAS。
|
||||
pub struct CodexCredentialAdminService {
|
||||
refresher: Arc<dyn TokenRefresher>,
|
||||
verifier: Arc<dyn CodexAccountIdentityVerifier>,
|
||||
account_source: Arc<dyn CodexAuthenticatedAccountSource>,
|
||||
leases: Arc<dyn ProviderLeasePort>,
|
||||
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
|
||||
}
|
||||
@@ -598,7 +652,7 @@ impl fmt::Debug for CodexCredentialAdminService {
|
||||
formatter
|
||||
.debug_struct("CodexCredentialAdminService")
|
||||
.field("refresher", &"TokenRefresher")
|
||||
.field("verifier", &"CodexAccountIdentityVerifier")
|
||||
.field("account_source", &"CodexAuthenticatedAccountSource")
|
||||
.field("leases", &"ProviderLeasePort")
|
||||
.field("runtime_policy", &"ProviderRuntimePolicyPort")
|
||||
.finish()
|
||||
@@ -608,19 +662,19 @@ impl fmt::Debug for CodexCredentialAdminService {
|
||||
impl CodexCredentialAdminService {
|
||||
pub fn new(
|
||||
refresher: Arc<dyn TokenRefresher>,
|
||||
verifier: Arc<dyn CodexAccountIdentityVerifier>,
|
||||
account_source: Arc<dyn CodexAuthenticatedAccountSource>,
|
||||
leases: Arc<dyn ProviderLeasePort>,
|
||||
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
|
||||
) -> Self {
|
||||
Self {
|
||||
refresher,
|
||||
verifier,
|
||||
account_source,
|
||||
leases,
|
||||
runtime_policy,
|
||||
}
|
||||
}
|
||||
|
||||
/// 官方 RT exchange + AT 身份验证;结果由 App 在同一 revision/audit 事务中提交。
|
||||
/// 官方 RT exchange;结果由 App 在同一 revision/audit 事务中提交。
|
||||
pub async fn manual_refresh(
|
||||
&self,
|
||||
current: LoadedCredential,
|
||||
@@ -632,7 +686,6 @@ impl CodexCredentialAdminService {
|
||||
}
|
||||
let runtime = CodexCredentialCodec::decode(¤t.credential)
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
|
||||
let expectation = current_identity_expectation(¤t.account, &runtime)?;
|
||||
let oauth = runtime
|
||||
.authentication
|
||||
.oauth()
|
||||
@@ -677,9 +730,6 @@ impl CodexCredentialAdminService {
|
||||
.refresh(refresh_token.expose_secret())
|
||||
.await
|
||||
.map_err(map_refresh_failure)?;
|
||||
if tokens.access_token.trim().is_empty() || tokens.expires_in.is_zero() {
|
||||
return Err(CodexCredentialAdminError::InvalidCredential);
|
||||
}
|
||||
let secret = CodexOAuthSecret {
|
||||
access_token: SecretString::from(tokens.access_token),
|
||||
refresh_token: tokens
|
||||
@@ -688,24 +738,30 @@ impl CodexCredentialAdminService {
|
||||
.or_else(|| oauth.refresh_token.clone()),
|
||||
id_token: oauth.id_token.clone(),
|
||||
};
|
||||
let verified_account = self
|
||||
.verifier
|
||||
.verify(&secret, &expectation)
|
||||
.await
|
||||
.and_then(super::identity::CodexIdentityVerification::into_complete)
|
||||
.map_err(map_identity_error)?;
|
||||
Self::record_recovery_log(
|
||||
CodexOAuthRecoveryOperation::ManualRefresh,
|
||||
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 next_refresh_at = refresh_time(
|
||||
policy,
|
||||
&account_id,
|
||||
verified_account.access_token_expires_at,
|
||||
Some(access_token_expires_at),
|
||||
secret.refresh_token.is_some(),
|
||||
)?;
|
||||
let mut prepared = CodexCredentialAdmin.prepare_rotation(RotateManagedCodexCredential {
|
||||
let mut prepared = CodexCredentialAdmin.prepare_refreshed_oauth_rotation(
|
||||
current,
|
||||
secret,
|
||||
verified_account,
|
||||
access_token_expires_at,
|
||||
next_refresh_at,
|
||||
})?;
|
||||
)?;
|
||||
prepared.refresh_guards = Some(ProviderRefreshGuards {
|
||||
_capacity: capacity_guard,
|
||||
_account: account_guard,
|
||||
@@ -713,7 +769,7 @@ impl CodexCredentialAdminService {
|
||||
Ok(prepared)
|
||||
}
|
||||
|
||||
/// 从 OAuth token 对补全账号事实,并归一到唯一 `NewProviderAccount` 写入路径。
|
||||
/// 用 access token 补全账号事实,并归一到唯一 `NewProviderAccount` 写入路径。
|
||||
pub async fn prepare_import_document(
|
||||
&self,
|
||||
payload: Value,
|
||||
@@ -743,9 +799,23 @@ impl CodexCredentialAdminService {
|
||||
if !account_ids.insert(account_id.clone()) {
|
||||
return Err(CodexCredentialAdminError::InvalidInput);
|
||||
}
|
||||
let (secret, verified_account) = self
|
||||
.verify_import_tokens(access_token.clone(), refresh_token.clone())
|
||||
let typed_account_id = ProviderAccountId::new(account_id.clone())
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidInput)?;
|
||||
let (secret, access_token_expires_at) = self
|
||||
.resolve_import_tokens(
|
||||
&typed_account_id,
|
||||
access_token.clone(),
|
||||
refresh_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(),
|
||||
@@ -765,16 +835,17 @@ impl CodexCredentialAdminService {
|
||||
};
|
||||
let next_refresh_at = refresh_time(
|
||||
refresh_policy,
|
||||
&ProviderAccountId::new(account_id.clone())
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidInput)?,
|
||||
&typed_account_id,
|
||||
verified_account.access_token_expires_at,
|
||||
secret.refresh_token.is_some(),
|
||||
)?;
|
||||
CodexCredentialAdmin.prepare_import(ImportCodexOAuthCredential {
|
||||
account_id,
|
||||
name: verified_account
|
||||
.email
|
||||
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,
|
||||
@@ -815,11 +886,12 @@ impl CodexCredentialAdminService {
|
||||
Ok(PreparedCodexAccountImport { accounts })
|
||||
}
|
||||
|
||||
async fn verify_import_tokens(
|
||||
async fn resolve_import_tokens(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
access_token: Option<String>,
|
||||
refresh_token: Option<String>,
|
||||
) -> Result<(CodexOAuthSecret, CodexAccountProfile), CodexCredentialAdminError> {
|
||||
) -> Result<(CodexOAuthSecret, Option<DateTime<Utc>>), CodexCredentialAdminError> {
|
||||
let access_token = access_token
|
||||
.as_deref()
|
||||
.map(normalize_bearer)
|
||||
@@ -831,19 +903,15 @@ impl CodexCredentialAdminService {
|
||||
if let Some(access_token) = access_token {
|
||||
let secret = CodexOAuthSecret {
|
||||
access_token: SecretString::from(access_token),
|
||||
refresh_token: refresh_token.clone().map(SecretString::from),
|
||||
refresh_token: refresh_token.map(SecretString::from),
|
||||
id_token: None,
|
||||
};
|
||||
match self.verifier.verify_import(&secret).await {
|
||||
Ok(profile) => return Ok((secret, profile)),
|
||||
Err(CodexIdentityVerificationError::Unavailable) => {
|
||||
return Err(CodexCredentialAdminError::IdentityUnavailable);
|
||||
}
|
||||
Err(CodexIdentityVerificationError::Rejected) if refresh_token.is_none() => {
|
||||
return Err(CodexCredentialAdminError::IdentityRejected);
|
||||
}
|
||||
Err(CodexIdentityVerificationError::Rejected) => {}
|
||||
}
|
||||
Self::record_recovery_log(
|
||||
CodexOAuthRecoveryOperation::ImportDirect,
|
||||
Some(account_id.as_str()),
|
||||
&secret,
|
||||
);
|
||||
return Ok((secret, None));
|
||||
}
|
||||
let refresh_token = refresh_token.ok_or(CodexCredentialAdminError::InvalidCredential)?;
|
||||
let tokens = self
|
||||
@@ -851,9 +919,6 @@ impl CodexCredentialAdminService {
|
||||
.refresh(&refresh_token)
|
||||
.await
|
||||
.map_err(map_refresh_failure)?;
|
||||
if tokens.access_token.trim().is_empty() || tokens.expires_in.is_zero() {
|
||||
return Err(CodexCredentialAdminError::InvalidCredential);
|
||||
}
|
||||
let secret = CodexOAuthSecret {
|
||||
access_token: SecretString::from(tokens.access_token),
|
||||
// RT 未轮换时仍保留导入提供的 RT,保证本次补全不会丢失后续刷新能力。
|
||||
@@ -863,12 +928,35 @@ impl CodexCredentialAdminService {
|
||||
.or_else(|| Some(SecretString::from(refresh_token))),
|
||||
id_token: None,
|
||||
};
|
||||
let profile = self
|
||||
.verifier
|
||||
.verify_import(&secret)
|
||||
.await
|
||||
.map_err(map_identity_error)?;
|
||||
Ok((secret, profile))
|
||||
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)))
|
||||
}
|
||||
|
||||
fn record_recovery_log(
|
||||
operation: CodexOAuthRecoveryOperation,
|
||||
account_id: Option<&str>,
|
||||
secret: &CodexOAuthSecret,
|
||||
) {
|
||||
record_oauth_recovery(
|
||||
operation,
|
||||
account_id,
|
||||
secret.access_token.expose_secret(),
|
||||
secret
|
||||
.refresh_token
|
||||
.as_ref()
|
||||
.map(ExposeSecret::expose_secret),
|
||||
);
|
||||
}
|
||||
|
||||
fn prepare_agent_identity_import(
|
||||
@@ -939,27 +1027,6 @@ impl CodexCredentialAdminService {
|
||||
}
|
||||
}
|
||||
|
||||
fn current_identity_expectation(
|
||||
account: &ProviderAccount,
|
||||
credential: &super::security::CodexRuntimeCredential,
|
||||
) -> Result<CodexIdentityExpectation, CodexCredentialAdminError> {
|
||||
let account_id = account
|
||||
.upstream_account_id()
|
||||
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
|
||||
let principal = credential
|
||||
.principal
|
||||
.as_ref()
|
||||
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
|
||||
CodexIdentityExpectation::current(
|
||||
principal.oauth_subject.clone(),
|
||||
principal.poid.clone(),
|
||||
account_id.to_owned(),
|
||||
account.upstream_user_id().to_owned(),
|
||||
credential.installation_id.clone(),
|
||||
)
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidCredential)
|
||||
}
|
||||
|
||||
fn required_time(
|
||||
value: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<SystemTime, CodexCredentialAdminError> {
|
||||
@@ -981,9 +1048,12 @@ pub(crate) fn refresh_time(
|
||||
if !has_refresh_token {
|
||||
return Ok(None);
|
||||
}
|
||||
let expires_at = required_time(access_token_expires_at)?;
|
||||
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, SystemTime::now())
|
||||
.next_attempt_at(account_id, expires_at, observed_at)
|
||||
.map(DateTime::<Utc>::from)
|
||||
.map(Some)
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidCredential)
|
||||
@@ -1016,15 +1086,6 @@ const fn map_refresh_failure(error: RefreshFailure) -> CodexCredentialAdminError
|
||||
}
|
||||
}
|
||||
|
||||
const fn map_identity_error(error: CodexIdentityVerificationError) -> CodexCredentialAdminError {
|
||||
match error {
|
||||
CodexIdentityVerificationError::Rejected => CodexCredentialAdminError::IdentityRejected,
|
||||
CodexIdentityVerificationError::Unavailable => {
|
||||
CodexCredentialAdminError::IdentityUnavailable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_import_document(
|
||||
payload: &Value,
|
||||
) -> Result<Vec<ParsedCodexImportAccount>, CodexCredentialAdminError> {
|
||||
@@ -1037,20 +1098,28 @@ fn parse_import_document(
|
||||
if !is_openai_oauth_candidate(value) {
|
||||
continue;
|
||||
}
|
||||
accounts.push(ParsedCodexImportAccount {
|
||||
id: None,
|
||||
name: None,
|
||||
email: None,
|
||||
plan_type: None,
|
||||
chatgpt_account_id: None,
|
||||
chatgpt_user_id: None,
|
||||
authentication: parse_oauth_import_tokens(value)?,
|
||||
status: None,
|
||||
});
|
||||
accounts.push(parse_oauth_import_account(value)?);
|
||||
}
|
||||
Ok(accounts)
|
||||
}
|
||||
|
||||
fn parse_oauth_import_account(
|
||||
value: &Value,
|
||||
) -> Result<ParsedCodexImportAccount, CodexCredentialAdminError> {
|
||||
let credentials = value.get("credentials").unwrap_or(value);
|
||||
Ok(ParsedCodexImportAccount {
|
||||
id: first_string(value, &["id"]),
|
||||
name: first_string(value, &["name", "label"]),
|
||||
email: first_string(value, &["email"]).or_else(|| first_string(credentials, &["email"])),
|
||||
plan_type: first_string(value, &["plan_type", "planType"])
|
||||
.or_else(|| first_string(credentials, &["plan_type", "planType"])),
|
||||
chatgpt_account_id: None,
|
||||
chatgpt_user_id: None,
|
||||
authentication: parse_oauth_import_tokens(value)?,
|
||||
status: first_string(value, &["status"]),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_agent_identity_account(
|
||||
value: &Value,
|
||||
) -> Result<ParsedCodexImportAccount, CodexCredentialAdminError> {
|
||||
@@ -1212,6 +1281,59 @@ fn normalize_bearer(value: &str) -> String {
|
||||
.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
|
||||
.email
|
||||
.as_deref()
|
||||
.and_then(|value| normalize_import_text(value, MAX_IMPORT_EMAIL_BYTES));
|
||||
}
|
||||
if profile.plan_type.is_none() {
|
||||
profile.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))
|
||||
.then(|| value.to_owned())
|
||||
}
|
||||
|
||||
fn import_runtime_state(
|
||||
status: Option<&str>,
|
||||
) -> Result<(bool, AccountAvailability), CodexCredentialAdminError> {
|
||||
|
||||
@@ -8,6 +8,7 @@ mod cookie;
|
||||
mod identity;
|
||||
mod oauth;
|
||||
mod quota;
|
||||
mod recovery_log;
|
||||
mod refresh;
|
||||
mod repository;
|
||||
mod security;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
//! Codex Authorization Code + PKCE/OIDC 管理流。
|
||||
|
||||
use std::{fmt, sync::Arc, time::Duration};
|
||||
use std::{
|
||||
fmt,
|
||||
sync::Arc,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
@@ -13,7 +17,7 @@ use gateway_admin::model::{
|
||||
},
|
||||
};
|
||||
use gateway_core::engine::credential::{
|
||||
NewProviderAccount, ProviderAccountId, ProviderAccountStore,
|
||||
LoadedCredential, NewProviderAccount, ProviderAccountId, ProviderAccountStore,
|
||||
};
|
||||
use gateway_core::provider_ports::ProviderRuntimePolicyPort;
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
@@ -24,11 +28,11 @@ use uuid::Uuid;
|
||||
|
||||
use super::admin::{
|
||||
CodexCredentialAdmin, CodexCredentialAdminError, ImportCodexOAuthCredential,
|
||||
PreparedCodexCredentialRotation, RotateManagedCodexCredential, refresh_time,
|
||||
};
|
||||
use super::identity::{
|
||||
CodexAccountIdentityVerifier, CodexIdentityExpectation, CodexIdentityVerificationError,
|
||||
PreparedCodexCredentialRotation, complete_oauth_account_profile, refresh_time,
|
||||
};
|
||||
use super::identity::{CodexAuthenticatedAccountSource, CodexIdentityVerificationError};
|
||||
use super::recovery_log::{CodexOAuthRecoveryOperation, record_oauth_recovery};
|
||||
use super::security::CodexCredentialCodec;
|
||||
use super::token_client::{
|
||||
AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant,
|
||||
OFFICIAL_CODEX_OAUTH_CLIENT_ID, OFFICIAL_CODEX_REDIRECT_URI,
|
||||
@@ -344,7 +348,7 @@ pub trait CodexOAuthAdmin: Send + Sync {
|
||||
pub struct CodexOAuthAdminService {
|
||||
pending: Arc<dyn CodexOAuthPendingStore>,
|
||||
exchanger: Arc<dyn AuthorizationCodeExchanger>,
|
||||
verifier: Arc<dyn CodexAccountIdentityVerifier>,
|
||||
account_source: Arc<dyn CodexAuthenticatedAccountSource>,
|
||||
store: Arc<dyn ProviderAccountStore>,
|
||||
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
|
||||
credentials: CodexCredentialAdmin,
|
||||
@@ -396,7 +400,7 @@ impl CodexOAuthAdminService {
|
||||
pub fn new(
|
||||
pending: Arc<dyn CodexOAuthPendingStore>,
|
||||
exchanger: Arc<dyn AuthorizationCodeExchanger>,
|
||||
verifier: Arc<dyn CodexAccountIdentityVerifier>,
|
||||
account_source: Arc<dyn CodexAuthenticatedAccountSource>,
|
||||
store: Arc<dyn ProviderAccountStore>,
|
||||
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
|
||||
credentials: CodexCredentialAdmin,
|
||||
@@ -404,7 +408,7 @@ impl CodexOAuthAdminService {
|
||||
Self {
|
||||
pending,
|
||||
exchanger,
|
||||
verifier,
|
||||
account_source,
|
||||
store,
|
||||
runtime_policy,
|
||||
credentials,
|
||||
@@ -487,8 +491,6 @@ impl CodexOAuthAdminService {
|
||||
callback_url: &str,
|
||||
) -> Result<(PendingAuthorizationMutation, CompletedCodexOAuthCredential), CodexOAuthAdminError>
|
||||
{
|
||||
let (mut secret, id_token) = self.exchange_pending(&pending, callback_url).await?;
|
||||
let mutation = pending.mutation.clone();
|
||||
let current = if let Some(target) = pending.reauthorization() {
|
||||
let current = self
|
||||
.store
|
||||
@@ -499,13 +501,19 @@ impl CodexOAuthAdminService {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let expectation = CodexIdentityExpectation::default();
|
||||
let profile = self
|
||||
.verifier
|
||||
.verify_authorization(&secret, &id_token, &pending.nonce, &expectation)
|
||||
.await
|
||||
.and_then(super::identity::CodexIdentityVerification::into_complete)
|
||||
.map_err(map_identity_error)?;
|
||||
let fallback_refresh_token = current
|
||||
.as_ref()
|
||||
.map(current_oauth_refresh_token)
|
||||
.transpose()?
|
||||
.flatten();
|
||||
let (mut secret, id_token, expires_in) = self
|
||||
.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)?;
|
||||
secret.id_token = Some(id_token);
|
||||
let policy = self
|
||||
.runtime_policy
|
||||
@@ -516,28 +524,35 @@ impl CodexOAuthAdminService {
|
||||
let next_refresh_at = refresh_time(
|
||||
policy,
|
||||
current.account.id(),
|
||||
profile.access_token_expires_at,
|
||||
Some(access_token_expires_at),
|
||||
secret.refresh_token.is_some(),
|
||||
)
|
||||
.map_err(map_admin_error)?;
|
||||
CompletedCodexOAuthCredential::Reauthorize(
|
||||
self.credentials
|
||||
.prepare_reauthorization(RotateManagedCodexCredential {
|
||||
.prepare_refreshed_oauth_rotation(
|
||||
current,
|
||||
secret,
|
||||
verified_account: profile,
|
||||
access_token_expires_at,
|
||||
next_refresh_at,
|
||||
})
|
||||
)
|
||||
.map_err(map_admin_error)?,
|
||||
)
|
||||
} 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,
|
||||
profile.access_token_expires_at,
|
||||
Some(access_token_expires_at),
|
||||
secret.refresh_token.is_some(),
|
||||
)
|
||||
.map_err(map_admin_error)?;
|
||||
@@ -654,7 +669,9 @@ impl CodexOAuthAdminService {
|
||||
&self,
|
||||
pending: &CodexPendingAuthorization,
|
||||
callback_url: &str,
|
||||
) -> Result<(super::types::CodexOAuthSecret, SecretString), CodexOAuthAdminError> {
|
||||
fallback_refresh_token: Option<SecretString>,
|
||||
) -> Result<(super::types::CodexOAuthSecret, SecretString, Duration), CodexOAuthAdminError>
|
||||
{
|
||||
let (code, callback_state) = callback_parts(callback_url)?;
|
||||
if !constant_time_equal(
|
||||
pending.state.expose_secret().as_bytes(),
|
||||
@@ -670,10 +687,36 @@ impl CodexOAuthAdminService {
|
||||
})
|
||||
.await
|
||||
.map_err(map_exchange_error)?;
|
||||
Ok((tokens.secret, tokens.id_token))
|
||||
let mut secret = tokens.secret;
|
||||
if secret.refresh_token.is_none() {
|
||||
secret.refresh_token = fallback_refresh_token;
|
||||
}
|
||||
record_oauth_recovery(
|
||||
CodexOAuthRecoveryOperation::AuthorizationCode,
|
||||
pending
|
||||
.reauthorization()
|
||||
.map(|account_id| account_id.as_str()),
|
||||
secret.access_token.expose_secret(),
|
||||
secret
|
||||
.refresh_token
|
||||
.as_ref()
|
||||
.map(ExposeSecret::expose_secret),
|
||||
);
|
||||
Ok((secret, tokens.id_token, tokens.expires_in))
|
||||
}
|
||||
}
|
||||
|
||||
fn current_oauth_refresh_token(
|
||||
current: &LoadedCredential,
|
||||
) -> Result<Option<SecretString>, CodexOAuthAdminError> {
|
||||
let runtime = CodexCredentialCodec::decode(¤t.credential)
|
||||
.map_err(|_| CodexOAuthAdminError::Credential)?;
|
||||
Ok(runtime
|
||||
.authentication
|
||||
.oauth()
|
||||
.and_then(|oauth| oauth.refresh_token.clone()))
|
||||
}
|
||||
|
||||
fn authorization_url(
|
||||
pending: &CodexPendingAuthorization,
|
||||
oauth_client_id: &str,
|
||||
@@ -699,27 +742,19 @@ fn authorization_url(
|
||||
|
||||
fn callback_parts(value: &str) -> Result<(SecretString, SecretString), CodexOAuthAdminError> {
|
||||
let url = Url::parse(value).map_err(|_| CodexOAuthAdminError::CallbackRejected)?;
|
||||
if url.scheme() != "http"
|
||||
|| url.host_str() != Some("localhost")
|
||||
|| url.port() != Some(1455)
|
||||
|| url.path() != "/auth/callback"
|
||||
|| url.fragment().is_some()
|
||||
{
|
||||
return Err(CodexOAuthAdminError::CallbackRejected);
|
||||
}
|
||||
let mut code = None;
|
||||
let mut state = None;
|
||||
for (key, value) in url.query_pairs() {
|
||||
match key.as_ref() {
|
||||
"code" => set_unique_callback_parameter(&mut code, value.into_owned())?,
|
||||
"state" => set_unique_callback_parameter(&mut state, value.into_owned())?,
|
||||
// OAuth 服务可能附加 `scope`、`iss` 等标准参数。
|
||||
// 固定回调地址与唯一的 `code`/`state` 才是安全边界,无关参数不参与校验。
|
||||
// 回调地址只承载 query 参数;安全绑定由唯一的 code/state、
|
||||
// 服务端保存的 state 和 PKCE token exchange 共同完成。
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let code = code.filter(|value| valid_secret(value));
|
||||
let state = state.filter(|value| valid_secret(value));
|
||||
let code = code.filter(|value| !value.is_empty());
|
||||
let state = state.filter(|value| !value.is_empty());
|
||||
match (code, state) {
|
||||
(Some(code), Some(state)) => Ok((SecretString::from(code), SecretString::from(state))),
|
||||
_ => Err(CodexOAuthAdminError::CallbackRejected),
|
||||
@@ -813,9 +848,9 @@ fn map_exchange_error(error: AuthorizationCodeExchangeError) -> CodexOAuthAdminE
|
||||
}
|
||||
}
|
||||
|
||||
fn map_identity_error(error: CodexIdentityVerificationError) -> CodexOAuthAdminError {
|
||||
const fn map_account_profile_error(error: CodexIdentityVerificationError) -> CodexOAuthAdminError {
|
||||
match error {
|
||||
CodexIdentityVerificationError::Rejected => CodexOAuthAdminError::IdentityRejected,
|
||||
CodexIdentityVerificationError::Rejected => CodexOAuthAdminError::TokenRejected,
|
||||
CodexIdentityVerificationError::Unavailable => CodexOAuthAdminError::UpstreamUnavailable,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//! 成功取得 OpenAI OAuth token 后写入普通应用日志的恢复记录。
|
||||
//!
|
||||
//! 此日志会刻意保存原始 AT/RT,避免后续数据库写入或校验失败时,已成功
|
||||
//! 交换的账号无法恢复。记录直接进入现有结构化日志,沿用其滚动和清理策略。
|
||||
|
||||
const RECOVERY_LOG_TARGET: &str = "openai_oauth_recovery";
|
||||
|
||||
/// 产生可恢复 token 组的 OAuth 操作。
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum CodexOAuthRecoveryOperation {
|
||||
/// 直接提供 AT/RT 的导入。
|
||||
ImportDirect,
|
||||
/// 仅提供 RT 的导入,先交换得到 AT。
|
||||
ImportRefreshToken,
|
||||
/// 管理员手动触发的 RT 交换。
|
||||
ManualRefresh,
|
||||
/// 调度器触发的 RT 交换。
|
||||
ScheduledRefresh,
|
||||
/// 授权码加 PKCE 的 token 交换。
|
||||
AuthorizationCode,
|
||||
}
|
||||
|
||||
impl CodexOAuthRecoveryOperation {
|
||||
const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::ImportDirect => "import_direct",
|
||||
Self::ImportRefreshToken => "import_refresh_token",
|
||||
Self::ManualRefresh => "manual_refresh",
|
||||
Self::ScheduledRefresh => "scheduled_refresh",
|
||||
Self::AuthorizationCode => "authorization_code",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 将已取得的 AT/RT 写入普通结构化日志。
|
||||
///
|
||||
/// 调用方必须在任何可能拒绝响应的本地校验、画像补全或数据库写入之前调用。
|
||||
/// 该调用不执行额外 I/O,也不返回业务错误。Host 负责普通日志文件的分割、清理和
|
||||
/// 输出;日志系统不可用时按其既有策略处理,不能阻断 OAuth 业务流程。
|
||||
pub(crate) fn record_oauth_recovery(
|
||||
operation: CodexOAuthRecoveryOperation,
|
||||
account_id: Option<&str>,
|
||||
access_token: &str,
|
||||
refresh_token: Option<&str>,
|
||||
) {
|
||||
let has_refresh_token = refresh_token.is_some();
|
||||
let refresh_token = refresh_token.unwrap_or_default();
|
||||
tracing::info!(
|
||||
target: RECOVERY_LOG_TARGET,
|
||||
event = "openai_oauth_recovery",
|
||||
operation = operation.as_str(),
|
||||
account_id = account_id.unwrap_or_default(),
|
||||
access_token,
|
||||
refresh_token,
|
||||
has_refresh_token,
|
||||
"OpenAI OAuth recovery token record"
|
||||
);
|
||||
}
|
||||
@@ -16,13 +16,11 @@ use gateway_core::provider_ports::{
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use thiserror::Error;
|
||||
|
||||
use super::identity::{
|
||||
CodexAccountIdentityVerifier, CodexIdentityExpectation, CodexIdentityVerification,
|
||||
CodexIdentityVerificationError,
|
||||
};
|
||||
use super::identity::CodexAccountIdentityVerifier;
|
||||
use super::recovery_log::{CodexOAuthRecoveryOperation, record_oauth_recovery};
|
||||
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
|
||||
use super::token_client::{RefreshFailure, TokenPair, TokenRefresher};
|
||||
use super::types::{CodexCredentialPrincipal, CodexOAuthSecret, RotateCodexCredential};
|
||||
use super::types::CodexOAuthSecret;
|
||||
|
||||
const PROVIDER_NAME: &str = "openai";
|
||||
const MAX_REFRESH_BATCH: u32 = 1_000;
|
||||
@@ -117,8 +115,6 @@ fn refresh_due_at(account: &ProviderAccount, now: SystemTime) -> Option<SystemTi
|
||||
pub struct DueCodexCredential {
|
||||
pub account: ProviderAccount,
|
||||
pub secret: CodexOAuthSecret,
|
||||
principal: CodexCredentialPrincipal,
|
||||
installation_id: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DueCodexCredential {
|
||||
@@ -170,7 +166,6 @@ pub enum CodexCredentialRefreshError {
|
||||
pub struct CodexCredentialRefreshService {
|
||||
repository: CodexCredentialRepository,
|
||||
refresher: Arc<dyn TokenRefresher>,
|
||||
identity: Arc<dyn CodexAccountIdentityVerifier>,
|
||||
leases: Arc<dyn ProviderLeasePort>,
|
||||
credential_state: Arc<dyn ProviderCredentialStatePort>,
|
||||
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
|
||||
@@ -180,7 +175,7 @@ impl CodexCredentialRefreshService {
|
||||
pub fn new(
|
||||
repository: CodexCredentialRepository,
|
||||
refresher: Arc<dyn TokenRefresher>,
|
||||
identity: Arc<dyn CodexAccountIdentityVerifier>,
|
||||
_identity: Arc<dyn CodexAccountIdentityVerifier>,
|
||||
leases: Arc<dyn ProviderLeasePort>,
|
||||
credential_state: Arc<dyn ProviderCredentialStatePort>,
|
||||
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
|
||||
@@ -188,7 +183,6 @@ impl CodexCredentialRefreshService {
|
||||
Self {
|
||||
repository,
|
||||
refresher,
|
||||
identity,
|
||||
leases,
|
||||
credential_state,
|
||||
runtime_policy,
|
||||
@@ -370,15 +364,9 @@ impl CodexCredentialRefreshService {
|
||||
let Some(secret) = runtime.authentication.oauth() else {
|
||||
unreachable!("OAuth runtime authentication was checked above")
|
||||
};
|
||||
let Some(principal) = runtime.principal else {
|
||||
failures.push(CodexCredentialRefreshOutcome::Failed { account_id });
|
||||
continue;
|
||||
};
|
||||
due.push(DueCodexCredential {
|
||||
account,
|
||||
secret: secret.clone(),
|
||||
principal,
|
||||
installation_id: runtime.installation_id,
|
||||
});
|
||||
}
|
||||
Ok(_) | Err(_) => {
|
||||
@@ -395,125 +383,47 @@ impl CodexCredentialRefreshService {
|
||||
tokens: TokenPair,
|
||||
policy: ProviderRefreshPolicy,
|
||||
) -> Result<CodexCredentialRefreshOutcome, CodexCredentialRefreshError> {
|
||||
if tokens.access_token.is_empty() || tokens.expires_in.is_zero() {
|
||||
return Err(CodexCredentialRefreshError::InvalidRefreshResponse);
|
||||
}
|
||||
let refresh_token = tokens
|
||||
.refresh_token
|
||||
let TokenPair {
|
||||
access_token,
|
||||
refresh_token: rotated_refresh_token,
|
||||
expires_in,
|
||||
} = tokens;
|
||||
let refresh_token = rotated_refresh_token
|
||||
.map(SecretString::from)
|
||||
.or(due.secret.refresh_token);
|
||||
let account_id = due
|
||||
.account
|
||||
.upstream_account_id()
|
||||
.ok_or(CodexCredentialRefreshError::InvalidRefreshResponse)?;
|
||||
let expectation = CodexIdentityExpectation::current(
|
||||
due.principal.oauth_subject,
|
||||
due.principal.poid,
|
||||
account_id.to_owned(),
|
||||
due.account.upstream_user_id().to_owned(),
|
||||
due.installation_id,
|
||||
)
|
||||
.map_err(|_| CodexCredentialRefreshError::InvalidRefreshResponse)?;
|
||||
let secret = CodexOAuthSecret {
|
||||
access_token: SecretString::from(tokens.access_token),
|
||||
access_token: SecretString::from(access_token),
|
||||
refresh_token,
|
||||
id_token: due.secret.id_token,
|
||||
};
|
||||
let verification = match self.identity.verify(&secret, &expectation).await {
|
||||
Ok(verification) => verification,
|
||||
Err(CodexIdentityVerificationError::Rejected) => {
|
||||
return self
|
||||
.persist_terminal(
|
||||
&due.account,
|
||||
AccountAvailability::Invalid,
|
||||
"identity_rejected",
|
||||
CodexCredentialRefreshOutcome::Invalidated {
|
||||
account_id: due.account.id().to_string(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(CodexIdentityVerificationError::Unavailable) => {
|
||||
// JWKS/签名边界短暂不可用属瞬态:保留现有凭据、推进退避重试,
|
||||
// 不终态失效账号。
|
||||
return if self
|
||||
.defer_refresh(&due.account, "identity-unavailable")
|
||||
.await?
|
||||
{
|
||||
Ok(CodexCredentialRefreshOutcome::Transient {
|
||||
account_id: due.account.id().to_string(),
|
||||
})
|
||||
} else {
|
||||
Ok(CodexCredentialRefreshOutcome::Stale {
|
||||
account_id: due.account.id().to_string(),
|
||||
})
|
||||
};
|
||||
}
|
||||
};
|
||||
let profile = match verification {
|
||||
CodexIdentityVerification::Complete(profile) => profile,
|
||||
CodexIdentityVerification::SignedOnly(signed) => {
|
||||
let attempt = self
|
||||
.credential_state
|
||||
.record_refresh_backoff(due.account.id(), REFRESH_BACKOFF_WINDOW)
|
||||
.await
|
||||
.unwrap_or(1);
|
||||
let access_token_expires_at =
|
||||
Some(SystemTime::from(signed.access_token_expires_at()));
|
||||
let retry_at = oauth_refresh_retry_at(
|
||||
due.account.id(),
|
||||
access_token_expires_at,
|
||||
SystemTime::now(),
|
||||
attempt,
|
||||
"identity-completion",
|
||||
)?;
|
||||
match self
|
||||
.repository
|
||||
.rotate_signed_secret(&due.account, secret, &signed, retry_at)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
log_refresh_deferred(
|
||||
due.account.id(),
|
||||
access_token_expires_at,
|
||||
attempt,
|
||||
"identity-completion",
|
||||
retry_at,
|
||||
);
|
||||
return Ok(CodexCredentialRefreshOutcome::Transient {
|
||||
account_id: due.account.id().to_string(),
|
||||
});
|
||||
}
|
||||
Err(CredentialRepositoryError::RevisionConflict) => {
|
||||
return Ok(CodexCredentialRefreshOutcome::Stale {
|
||||
account_id: due.account.id().to_string(),
|
||||
});
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
};
|
||||
let access_token_expires_at = profile.access_token_expires_at.map(SystemTime::from);
|
||||
record_oauth_recovery(
|
||||
CodexOAuthRecoveryOperation::ScheduledRefresh,
|
||||
Some(due.account.id().as_str()),
|
||||
secret.access_token.expose_secret(),
|
||||
secret
|
||||
.refresh_token
|
||||
.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(),
|
||||
profile
|
||||
.access_token_expires_at
|
||||
.map(SystemTime::from)
|
||||
.ok_or(CodexCredentialRefreshError::InvalidRefreshResponse)?,
|
||||
SystemTime::now(),
|
||||
)
|
||||
.next_attempt_at(due.account.id(), access_token_expires_at, observed_at)
|
||||
.map(DateTime::<Utc>::from)
|
||||
.map_err(CodexCredentialRefreshError::from)?;
|
||||
let result = self
|
||||
.repository
|
||||
.rotate_oauth_secret(RotateCodexCredential {
|
||||
account_id: due.account.id().to_string(),
|
||||
expected_credential_revision: due.account.revision().get(),
|
||||
.rotate_refreshed_oauth_secret(
|
||||
&due.account,
|
||||
secret,
|
||||
verified_account: profile,
|
||||
next_refresh_at: Some(next_refresh_at),
|
||||
})
|
||||
access_token_expires_at,
|
||||
SystemTime::from(next_refresh_at),
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(revision) => {
|
||||
@@ -525,7 +435,7 @@ impl CodexCredentialRefreshService {
|
||||
tracing::info!(
|
||||
account_id = %due.account.id(),
|
||||
credential_revision = revision.get(),
|
||||
access_token_expires_at = ?access_token_expires_at.map(DateTime::<Utc>::from),
|
||||
access_token_expires_at = %DateTime::<Utc>::from(access_token_expires_at),
|
||||
next_refresh_at = %next_refresh_at,
|
||||
"OpenAI OAuth refresh succeeded"
|
||||
);
|
||||
|
||||
@@ -13,7 +13,6 @@ use gateway_core::routing::ProviderKind;
|
||||
use secrecy::ExposeSecret;
|
||||
use thiserror::Error;
|
||||
|
||||
use super::identity::CodexSignedIdentity;
|
||||
use super::security::{CodexCredentialCodec, CodexCredentialDataError, CodexRuntimeCredential};
|
||||
use super::types::{
|
||||
CodexAccountProfile, CodexCredentialData, CodexOAuthSecret, RotateCodexCredential,
|
||||
@@ -83,12 +82,15 @@ impl CodexCredentialRepository {
|
||||
cas_revision(self.store.compare_and_swap_credential(update).await?)
|
||||
}
|
||||
|
||||
/// usage 暂时不可用时,只在签名 principal 与现有绑定完全一致后保存已轮换 token。
|
||||
pub async fn rotate_signed_secret(
|
||||
/// 持久化成功 RT exchange 的 token,同时保留既有账号身份投影。
|
||||
///
|
||||
/// Refresh endpoint 已经是这次 AT/RT 轮换的授权边界;这里仅以 revision
|
||||
/// CAS 保护并发写入,不重新验证新 access token 的身份声明。
|
||||
pub async fn rotate_refreshed_oauth_secret(
|
||||
&self,
|
||||
account: &ProviderAccount,
|
||||
secret: CodexOAuthSecret,
|
||||
signed: &CodexSignedIdentity,
|
||||
access_token_expires_at: SystemTime,
|
||||
next_refresh_at: SystemTime,
|
||||
) -> Result<CredentialRevision, CredentialRepositoryError> {
|
||||
let current = self
|
||||
@@ -102,17 +104,6 @@ impl CodexCredentialRepository {
|
||||
let oauth = data
|
||||
.oauth_mut()
|
||||
.ok_or(CredentialRepositoryError::InvalidCredentialData)?;
|
||||
if oauth.principal.oauth_subject != signed.oauth_subject()
|
||||
|| oauth.principal.poid.as_deref() != signed.poid()
|
||||
|| signed
|
||||
.claimed_account_id()
|
||||
.is_some_and(|value| account.upstream_account_id() != Some(value))
|
||||
|| signed
|
||||
.claimed_user_id()
|
||||
.is_some_and(|value| account.upstream_user_id() != value)
|
||||
{
|
||||
return Err(CredentialRepositoryError::IdentityMismatch);
|
||||
}
|
||||
oauth.access_token = secret.access_token.expose_secret().to_owned();
|
||||
oauth.refresh_token = secret
|
||||
.refresh_token
|
||||
@@ -129,7 +120,7 @@ impl CodexCredentialRepository {
|
||||
unchanged_profile(account),
|
||||
credential,
|
||||
secret.refresh_token.is_some(),
|
||||
Some(SystemTime::from(signed.access_token_expires_at())),
|
||||
Some(access_token_expires_at),
|
||||
Some(next_refresh_at),
|
||||
)
|
||||
.map_err(|_| CredentialRepositoryError::InvalidCredentialData)?;
|
||||
|
||||
@@ -76,10 +76,11 @@ impl fmt::Debug for AuthorizationCodeGrant {
|
||||
}
|
||||
}
|
||||
|
||||
/// 官方 token endpoint 返回、尚待 OIDC/JWKS 绑定校验的 token set。
|
||||
/// 官方 token endpoint 完成协议校验后返回的 token set。
|
||||
pub struct AuthorizationTokenSet {
|
||||
pub secret: crate::credential::CodexOAuthSecret,
|
||||
pub id_token: SecretString,
|
||||
pub expires_in: Duration,
|
||||
}
|
||||
|
||||
impl fmt::Debug for AuthorizationTokenSet {
|
||||
@@ -88,6 +89,7 @@ impl fmt::Debug for AuthorizationTokenSet {
|
||||
.debug_struct("AuthorizationTokenSet")
|
||||
.field("secret", &"[REDACTED]")
|
||||
.field("id_token", &"[REDACTED]")
|
||||
.field("expires_in", &self.expires_in)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -256,7 +258,7 @@ impl AuthorizationCodeExchanger for OpenAiTokenClient {
|
||||
|| tokens
|
||||
.refresh_token
|
||||
.as_deref()
|
||||
.is_some_and(|token| token.is_empty())
|
||||
.is_some_and(|token| token.trim().is_empty())
|
||||
{
|
||||
return Err(AuthorizationCodeExchangeError::Rejected);
|
||||
}
|
||||
@@ -267,6 +269,7 @@ impl AuthorizationCodeExchanger for OpenAiTokenClient {
|
||||
id_token: None,
|
||||
},
|
||||
id_token: SecretString::from(tokens.id_token),
|
||||
expires_in: Duration::from_secs(tokens.expires_in),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -302,7 +305,11 @@ fn parse_token_pair(body: &[u8]) -> Result<TokenPair, ()> {
|
||||
}
|
||||
Ok(TokenPair {
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ impl fmt::Debug for CodexOAuthSecret {
|
||||
}
|
||||
}
|
||||
|
||||
/// 已由官方 JWT/OIDC 与认证 usage 响应共同确认的完整账号投影。
|
||||
/// OAuth credential 持久化时使用的账号投影。
|
||||
#[derive(Clone)]
|
||||
pub struct CodexAccountProfile {
|
||||
pub email: Option<String>,
|
||||
|
||||
@@ -143,6 +143,7 @@ pub async fn initialize(
|
||||
.with_session_identity(session_identity),
|
||||
);
|
||||
|
||||
tracing::info!(target: "openai_oauth_recovery", "OpenAI OAuth recovery records use normal application logs");
|
||||
let token_client = Arc::new(
|
||||
credential::token_client::openai_token_client(config.token_client_config())
|
||||
.map_err(|_| OpenAiInitializeError::TokenClient)?,
|
||||
@@ -156,13 +157,14 @@ pub async fn initialize(
|
||||
ReqwestCodexAuthenticatedAccountSource::new(profile.clone(), config.base_url().to_owned())
|
||||
.map_err(|_| OpenAiInitializeError::Identity)?,
|
||||
);
|
||||
let identity: Arc<dyn CodexAccountIdentityVerifier> =
|
||||
Arc::new(CodexAccountIdentityService::new(signed, account_source));
|
||||
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(&identity),
|
||||
Arc::clone(&account_source),
|
||||
Arc::clone(&leases),
|
||||
Arc::clone(&runtime_policy),
|
||||
));
|
||||
@@ -182,7 +184,7 @@ pub async fn initialize(
|
||||
CodexOAuthAdminService::new(
|
||||
pending,
|
||||
exchanger,
|
||||
Arc::clone(&identity),
|
||||
account_source,
|
||||
Arc::clone(&accounts),
|
||||
Arc::clone(&runtime_policy),
|
||||
CodexCredentialAdmin,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
@@ -20,14 +21,15 @@ use gateway_core::provider_ports::{
|
||||
use gateway_core::routing::ProviderKind;
|
||||
use provider_openai::credential::token_client::{RefreshFailure, TokenPair, TokenRefresher};
|
||||
use provider_openai::credential::{
|
||||
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CodexAccountIdentityVerifier,
|
||||
CodexAgentIdentityAuthMode, CodexAgentIdentityCredentialData, CodexCredentialAdmin,
|
||||
CodexCredentialAdminError, CodexCredentialAdminService, CodexCredentialCodec,
|
||||
CodexIdentityExpectation, CodexIdentityVerification, CodexIdentityVerificationError,
|
||||
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CodexAgentIdentityAuthMode,
|
||||
CodexAgentIdentityCredentialData, CodexAuthenticatedAccount, CodexAuthenticatedAccountSource,
|
||||
CodexCredentialAdmin, CodexCredentialAdminError, CodexCredentialAdminService,
|
||||
CodexCredentialCodec, CodexIdentityExpectation, CodexIdentityVerificationError,
|
||||
ExportManagedCodexCredential, ImportCodexOAuthCredential, RotateManagedCodexCredential,
|
||||
};
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use secrecy::ExposeSecret;
|
||||
|
||||
use super::{OAuthRecoveryLogCapture, lock_oauth_recovery_log_capture};
|
||||
use crate::support::{MemoryAccountStore, codex_account, profile, runtime_policy, secret};
|
||||
|
||||
fn import(id: &str, token: &str) -> ImportCodexOAuthCredential {
|
||||
@@ -315,19 +317,25 @@ fn cpr_export_preserves_agent_identity_material_without_oauth_fields() {
|
||||
#[tokio::test]
|
||||
async fn cpr_export_and_import_support_mixed_oauth_and_agent_identity_accounts() {
|
||||
let (agent, _) = agent_export_item("acct_mixed_agent");
|
||||
let access_token = "opaque-mixed-oauth";
|
||||
let value = CodexCredentialAdmin
|
||||
.format_cpr_export(vec![
|
||||
export_item("acct_mixed_oauth", "token-acct_mixed_oauth"),
|
||||
agent,
|
||||
])
|
||||
.format_cpr_export(vec![export_item("acct_mixed_oauth", access_token), agent])
|
||||
.expect("mixed export")
|
||||
.into_json()
|
||||
.expect("serialize mixed export");
|
||||
|
||||
let prepared = import_service(unused_import_refresher())
|
||||
.prepare_import_document(value)
|
||||
.await
|
||||
.expect("mixed CPR import");
|
||||
let prepared = import_service_with_source(
|
||||
unused_import_refresher(),
|
||||
account_source([authenticated_account(
|
||||
"chatgpt-mixed-oauth",
|
||||
"user-mixed-oauth",
|
||||
None,
|
||||
None,
|
||||
)]),
|
||||
)
|
||||
.prepare_import_document(value)
|
||||
.await
|
||||
.expect("mixed CPR import");
|
||||
assert_eq!(prepared.accounts().len(), 2);
|
||||
assert!(prepared.accounts().iter().any(|account| {
|
||||
account.account.authentication_kind() == CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY
|
||||
@@ -360,34 +368,59 @@ impl TokenRefresher for ManualRefresher {
|
||||
}
|
||||
}
|
||||
|
||||
struct ManualVerifier;
|
||||
struct ManualAccountSource {
|
||||
outcomes: Mutex<VecDeque<Result<CodexAuthenticatedAccount, CodexIdentityVerificationError>>>,
|
||||
seen: Mutex<Vec<String>>,
|
||||
expectations: Mutex<Vec<CodexIdentityExpectation>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CodexAccountIdentityVerifier for ManualVerifier {
|
||||
async fn verify(
|
||||
impl CodexAuthenticatedAccountSource for ManualAccountSource {
|
||||
async fn fetch(
|
||||
&self,
|
||||
secret: &provider_openai::credential::CodexOAuthSecret,
|
||||
_expectation: &CodexIdentityExpectation,
|
||||
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
|
||||
if secret.access_token.expose_secret() != "refreshed-access" {
|
||||
return Err(CodexIdentityVerificationError::Rejected);
|
||||
}
|
||||
Ok(CodexIdentityVerification::Complete(profile(
|
||||
"chatgpt-acct_manual_refresh",
|
||||
)))
|
||||
expectation: &CodexIdentityExpectation,
|
||||
) -> Result<CodexAuthenticatedAccount, CodexIdentityVerificationError> {
|
||||
self.seen
|
||||
.lock()
|
||||
.expect("seen account-source access tokens")
|
||||
.push(secret.access_token.expose_secret().to_owned());
|
||||
self.expectations
|
||||
.lock()
|
||||
.expect("seen account-source expectations")
|
||||
.push(expectation.clone());
|
||||
self.outcomes
|
||||
.lock()
|
||||
.expect("account source outcomes")
|
||||
.pop_front()
|
||||
.unwrap_or(Err(CodexIdentityVerificationError::Unavailable))
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_authorization(
|
||||
&self,
|
||||
_secret: &provider_openai::credential::CodexOAuthSecret,
|
||||
_id_token: &SecretString,
|
||||
_expected_nonce: &SecretString,
|
||||
_expectation: &CodexIdentityExpectation,
|
||||
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
|
||||
Err(CodexIdentityVerificationError::Rejected)
|
||||
fn authenticated_account(
|
||||
account_id: &str,
|
||||
user_id: &str,
|
||||
email: Option<&str>,
|
||||
plan_type: Option<&str>,
|
||||
) -> CodexAuthenticatedAccount {
|
||||
CodexAuthenticatedAccount {
|
||||
chatgpt_account_id: account_id.to_owned(),
|
||||
chatgpt_user_id: user_id.to_owned(),
|
||||
email: email.map(str::to_owned),
|
||||
plan_type: plan_type.map(str::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
fn account_source(
|
||||
outcomes: impl IntoIterator<Item = CodexAuthenticatedAccount>,
|
||||
) -> Arc<ManualAccountSource> {
|
||||
Arc::new(ManualAccountSource {
|
||||
outcomes: Mutex::new(outcomes.into_iter().map(Ok).collect()),
|
||||
seen: Mutex::new(Vec::new()),
|
||||
expectations: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
struct DropCountGuard(Arc<AtomicUsize>);
|
||||
|
||||
impl Drop for DropCountGuard {
|
||||
@@ -473,7 +506,7 @@ async fn manual_refresh_fixture(
|
||||
});
|
||||
let service = CodexCredentialAdminService::new(
|
||||
refresher.clone(),
|
||||
Arc::new(ManualVerifier),
|
||||
account_source([]),
|
||||
leases.clone(),
|
||||
runtime_policy(),
|
||||
);
|
||||
@@ -489,7 +522,8 @@ fn refreshed_tokens() -> TokenPair {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_refresh_prepares_revision_fenced_rotation_without_store_mutation() {
|
||||
async fn manual_refresh_prepares_rotation_without_identity_verification() {
|
||||
let _capture_lock = lock_oauth_recovery_log_capture().await;
|
||||
let (store, refresher, leases, service) =
|
||||
manual_refresh_fixture(Ok(refreshed_tokens()), true).await;
|
||||
let account_id = ProviderAccountId::new("acct_manual_refresh").expect("account id");
|
||||
@@ -497,10 +531,14 @@ async fn manual_refresh_prepares_revision_fenced_rotation_without_store_mutation
|
||||
.load_current_credential(&account_id)
|
||||
.await
|
||||
.expect("current credential");
|
||||
let recovery_log = OAuthRecoveryLogCapture::default();
|
||||
let dispatch = recovery_log.dispatch();
|
||||
let guard = tracing::dispatcher::set_default(&dispatch);
|
||||
let prepared = service
|
||||
.manual_refresh(current)
|
||||
.await
|
||||
.expect("manual refresh");
|
||||
drop(guard);
|
||||
|
||||
assert_eq!(prepared.credential.account_id(), &account_id);
|
||||
assert_eq!(prepared.credential.expected_revision().get(), 1);
|
||||
@@ -539,6 +577,12 @@ async fn manual_refresh_prepares_revision_fenced_rotation_without_store_mutation
|
||||
["rt-old-access"]
|
||||
);
|
||||
assert_eq!(leases.drops.load(Ordering::SeqCst), 0);
|
||||
let record = recovery_log.recovery_record();
|
||||
assert_eq!(record["fields"]["operation"], "manual_refresh");
|
||||
assert_eq!(record["fields"]["account_id"], "acct_manual_refresh");
|
||||
assert_eq!(record["fields"]["access_token"], "refreshed-access");
|
||||
assert_eq!(record["fields"]["refresh_token"], "rotated-refresh");
|
||||
assert_eq!(record["fields"]["has_refresh_token"], true);
|
||||
drop(prepared);
|
||||
assert_eq!(leases.drops.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
@@ -569,54 +613,19 @@ async fn manual_refresh_missing_lease_fails_before_exchange() {
|
||||
);
|
||||
}
|
||||
|
||||
struct ImportVerifier;
|
||||
|
||||
#[async_trait]
|
||||
impl CodexAccountIdentityVerifier for ImportVerifier {
|
||||
async fn verify(
|
||||
&self,
|
||||
secret: &provider_openai::credential::CodexOAuthSecret,
|
||||
expectation: &CodexIdentityExpectation,
|
||||
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
|
||||
let suffix = secret
|
||||
.access_token
|
||||
.expose_secret()
|
||||
.strip_prefix("token-")
|
||||
.ok_or(CodexIdentityVerificationError::Rejected)?;
|
||||
let mut profile = profile(&format!("chatgpt-{suffix}"));
|
||||
if let Some(user) = suffix.strip_prefix("shared-") {
|
||||
profile.chatgpt_account_id = "chatgpt-shared".to_owned();
|
||||
profile.chatgpt_user_id = format!("user-{user}");
|
||||
}
|
||||
if expectation
|
||||
.chatgpt_account_id()
|
||||
.is_some_and(|expected| expected != profile.chatgpt_account_id)
|
||||
|| expectation
|
||||
.chatgpt_user_id()
|
||||
.is_some_and(|expected| expected != profile.chatgpt_user_id)
|
||||
{
|
||||
return Err(CodexIdentityVerificationError::Rejected);
|
||||
}
|
||||
Ok(CodexIdentityVerification::Complete(profile))
|
||||
}
|
||||
|
||||
async fn verify_authorization(
|
||||
&self,
|
||||
_secret: &provider_openai::credential::CodexOAuthSecret,
|
||||
_id_token: &SecretString,
|
||||
_expected_nonce: &SecretString,
|
||||
_expectation: &CodexIdentityExpectation,
|
||||
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
|
||||
Err(CodexIdentityVerificationError::Rejected)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn import_service(
|
||||
refresher: Arc<ManualRefresher>,
|
||||
) -> provider_openai::credential::CodexCredentialAdminService {
|
||||
import_service_with_source(refresher, account_source([]))
|
||||
}
|
||||
|
||||
fn import_service_with_source(
|
||||
refresher: Arc<ManualRefresher>,
|
||||
source: Arc<ManualAccountSource>,
|
||||
) -> provider_openai::credential::CodexCredentialAdminService {
|
||||
CodexCredentialAdminService::new(
|
||||
refresher,
|
||||
Arc::new(ImportVerifier),
|
||||
source,
|
||||
Arc::new(ManualLeases {
|
||||
requests: Mutex::new(Vec::new()),
|
||||
drops: Arc::new(AtomicUsize::new(0)),
|
||||
@@ -634,21 +643,25 @@ pub(super) fn unused_import_refresher() -> Arc<ManualRefresher> {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oauth_import_uses_only_tokens_and_verified_identity() {
|
||||
async fn oauth_import_accepts_opaque_access_token_and_completes_profile_from_account_source() {
|
||||
let refresher = unused_import_refresher();
|
||||
let prepared = import_service(refresher.clone())
|
||||
let access_token = "opaque-cpr-access";
|
||||
let source = account_source([authenticated_account(
|
||||
"chatgpt-cpr",
|
||||
"user-cpr",
|
||||
Some("remote-cpr@example.com"),
|
||||
Some("team"),
|
||||
)]);
|
||||
let prepared = import_service_with_source(refresher.clone(), Arc::clone(&source))
|
||||
.prepare_import_document(serde_json::json!({
|
||||
"sourceFormat": "cpr",
|
||||
"accounts": [{
|
||||
"id": "acct_cpr_import",
|
||||
"email": "cpr@example.com",
|
||||
"accountId": "chatgpt-cpr",
|
||||
"userId": "user-chatgpt-cpr",
|
||||
"email": "untrusted-cpr@example.com",
|
||||
"label": "CPR account",
|
||||
"planType": "pro",
|
||||
"token": "Bearer token-cpr",
|
||||
"planType": "untrusted-plan",
|
||||
"token": format!("Bearer {access_token}"),
|
||||
"refreshToken": "refresh-cpr",
|
||||
"accessTokenExpiresAt": "2100-01-01T00:00:00+00:00",
|
||||
"status": "disabled",
|
||||
"addedAt": "2026-07-18T10:47:01+08:00",
|
||||
"updatedAt": "2026-07-19T11:00:00+08:00",
|
||||
@@ -662,6 +675,11 @@ async fn oauth_import_uses_only_tokens_and_verified_identity() {
|
||||
assert!(account.account.id().as_str().starts_with("acct_"));
|
||||
assert!(account.account.enabled());
|
||||
assert_eq!(account.account.upstream_account_id(), Some("chatgpt-cpr"));
|
||||
assert_eq!(account.account.upstream_user_id(), "user-cpr");
|
||||
assert_eq!(account.account.name(), "CPR account");
|
||||
assert_eq!(account.account.email(), Some("remote-cpr@example.com"));
|
||||
assert_eq!(account.account.plan_type(), Some("team"));
|
||||
assert_eq!(account.account.access_token_expires_at(), None);
|
||||
let runtime = CodexCredentialCodec::decode(&account.credential).expect("credential");
|
||||
assert_eq!(
|
||||
runtime
|
||||
@@ -670,26 +688,181 @@ async fn oauth_import_uses_only_tokens_and_verified_identity() {
|
||||
.expect("OAuth credential")
|
||||
.access_token
|
||||
.expose_secret(),
|
||||
"token-cpr"
|
||||
access_token
|
||||
);
|
||||
assert!(refresher.seen.lock().expect("seen tokens").is_empty());
|
||||
assert_eq!(
|
||||
source
|
||||
.seen
|
||||
.lock()
|
||||
.expect("seen account-source access tokens")
|
||||
.as_slice(),
|
||||
[access_token]
|
||||
);
|
||||
assert_eq!(
|
||||
source
|
||||
.expectations
|
||||
.lock()
|
||||
.expect("seen account-source expectations")
|
||||
.as_slice(),
|
||||
[CodexIdentityExpectation::default()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_import_logs_raw_tokens_before_account_profile_failure() {
|
||||
let _capture_lock = lock_oauth_recovery_log_capture().await;
|
||||
let recovery_log = OAuthRecoveryLogCapture::default();
|
||||
let dispatch = recovery_log.dispatch();
|
||||
let guard = tracing::dispatcher::set_default(&dispatch);
|
||||
let error = import_service_with_source(unused_import_refresher(), account_source([]))
|
||||
.prepare_import_document(serde_json::json!({
|
||||
"sourceFormat": "cpr",
|
||||
"accounts": [{
|
||||
"token": "logged-direct-access",
|
||||
"refreshToken": "logged-direct-refresh"
|
||||
}]
|
||||
}))
|
||||
.await
|
||||
.expect_err("profile source fails after direct import logging");
|
||||
drop(guard);
|
||||
|
||||
assert_eq!(error, CodexCredentialAdminError::AccountProfileUnavailable);
|
||||
let record = recovery_log.recovery_record();
|
||||
assert_eq!(record["fields"]["operation"], "import_direct");
|
||||
assert!(
|
||||
record["fields"]["account_id"]
|
||||
.as_str()
|
||||
.is_some_and(|account_id| account_id.starts_with("acct_"))
|
||||
);
|
||||
assert_eq!(record["fields"]["access_token"], "logged-direct-access");
|
||||
assert_eq!(record["fields"]["refresh_token"], "logged-direct-refresh");
|
||||
assert_eq!(record["fields"]["has_refresh_token"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_token_only_import_persists_exchanged_tokens_and_completes_profile() {
|
||||
let refreshed_access = "opaque-refreshed-import";
|
||||
let refresher = Arc::new(ManualRefresher {
|
||||
outcome: Mutex::new(Some(Ok(TokenPair {
|
||||
access_token: refreshed_access.to_owned(),
|
||||
refresh_token: Some("rotated-import-refresh".to_owned()),
|
||||
expires_in: Duration::from_secs(3_600),
|
||||
}))),
|
||||
seen: Mutex::new(Vec::new()),
|
||||
});
|
||||
let source = account_source([authenticated_account(
|
||||
"chatgpt-refreshed-import",
|
||||
"user-refreshed-import",
|
||||
Some("refreshed@example.com"),
|
||||
Some("plus"),
|
||||
)]);
|
||||
|
||||
let prepared = import_service_with_source(Arc::clone(&refresher), Arc::clone(&source))
|
||||
.prepare_import_document(serde_json::json!({
|
||||
"refreshToken": "refresh-only"
|
||||
}))
|
||||
.await
|
||||
.expect("refresh-token-only import");
|
||||
|
||||
assert_eq!(prepared.accounts().len(), 1);
|
||||
let account = &prepared.accounts()[0];
|
||||
assert_eq!(
|
||||
account.account.upstream_account_id(),
|
||||
Some("chatgpt-refreshed-import")
|
||||
);
|
||||
assert_eq!(account.account.upstream_user_id(), "user-refreshed-import");
|
||||
assert_eq!(account.account.email(), Some("refreshed@example.com"));
|
||||
assert_eq!(account.account.plan_type(), Some("plus"));
|
||||
let runtime = CodexCredentialCodec::decode(&account.credential).expect("credential");
|
||||
let oauth = runtime.authentication.oauth().expect("OAuth credential");
|
||||
assert_eq!(oauth.access_token.expose_secret(), refreshed_access);
|
||||
assert_eq!(
|
||||
oauth
|
||||
.refresh_token
|
||||
.as_ref()
|
||||
.expect("rotated RT")
|
||||
.expose_secret(),
|
||||
"rotated-import-refresh"
|
||||
);
|
||||
assert_eq!(
|
||||
refresher
|
||||
.seen
|
||||
.lock()
|
||||
.expect("seen refresh tokens")
|
||||
.as_slice(),
|
||||
["refresh-only"]
|
||||
);
|
||||
assert_eq!(
|
||||
source
|
||||
.seen
|
||||
.lock()
|
||||
.expect("seen account-source access tokens")
|
||||
.as_slice(),
|
||||
[refreshed_access]
|
||||
);
|
||||
assert_eq!(
|
||||
source
|
||||
.expectations
|
||||
.lock()
|
||||
.expect("seen account-source expectations")
|
||||
.as_slice(),
|
||||
[CodexIdentityExpectation::default()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_token_only_import_logs_fallback_refresh_token_before_account_profile_failure() {
|
||||
let _capture_lock = lock_oauth_recovery_log_capture().await;
|
||||
let recovery_log = OAuthRecoveryLogCapture::default();
|
||||
let refresher = Arc::new(ManualRefresher {
|
||||
outcome: Mutex::new(Some(Ok(TokenPair {
|
||||
access_token: "logged-import-access".to_owned(),
|
||||
refresh_token: None,
|
||||
expires_in: Duration::from_secs(3_600),
|
||||
}))),
|
||||
seen: Mutex::new(Vec::new()),
|
||||
});
|
||||
let dispatch = recovery_log.dispatch();
|
||||
let guard = tracing::dispatcher::set_default(&dispatch);
|
||||
let error = import_service_with_source(refresher, account_source([]))
|
||||
.prepare_import_document(serde_json::json!({
|
||||
"refreshToken": "logged-import-original-refresh"
|
||||
}))
|
||||
.await
|
||||
.expect_err("profile source fails after refresh-token import logging");
|
||||
drop(guard);
|
||||
|
||||
assert_eq!(error, CodexCredentialAdminError::AccountProfileUnavailable);
|
||||
let record = recovery_log.recovery_record();
|
||||
assert_eq!(record["fields"]["operation"], "import_refresh_token");
|
||||
assert_eq!(record["fields"]["access_token"], "logged-import-access");
|
||||
assert_eq!(
|
||||
record["fields"]["refresh_token"],
|
||||
"logged-import-original-refresh"
|
||||
);
|
||||
assert_eq!(record["fields"]["has_refresh_token"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cpr_batch_allows_distinct_users_in_the_same_workspace() {
|
||||
let prepared = import_service(unused_import_refresher())
|
||||
let source = account_source([
|
||||
authenticated_account("chatgpt-shared", "user-a", None, None),
|
||||
authenticated_account("chatgpt-shared", "user-b", None, None),
|
||||
]);
|
||||
let prepared = import_service_with_source(unused_import_refresher(), Arc::clone(&source))
|
||||
.prepare_import_document(serde_json::json!({
|
||||
"sourceFormat": "cpr",
|
||||
"accounts": [{
|
||||
"id": "acct_shared_a",
|
||||
"accountId": "chatgpt-shared",
|
||||
"userId": "user-a",
|
||||
"token": "token-shared-a"
|
||||
"token": "opaque-shared-a"
|
||||
}, {
|
||||
"id": "acct_shared_b",
|
||||
"accountId": "chatgpt-shared",
|
||||
"userId": "user-b",
|
||||
"token": "token-shared-b"
|
||||
"token": "opaque-shared-b"
|
||||
}]
|
||||
}))
|
||||
.await
|
||||
@@ -703,19 +876,23 @@ async fn cpr_batch_allows_distinct_users_in_the_same_workspace() {
|
||||
.all(|account| { account.account.upstream_account_id() == Some("chatgpt-shared") })
|
||||
);
|
||||
|
||||
let duplicate = import_service(unused_import_refresher())
|
||||
let duplicate_source = account_source([
|
||||
authenticated_account("chatgpt-shared", "user-a", None, None),
|
||||
authenticated_account("chatgpt-shared", "user-a", None, None),
|
||||
]);
|
||||
let duplicate = import_service_with_source(unused_import_refresher(), duplicate_source)
|
||||
.prepare_import_document(serde_json::json!({
|
||||
"sourceFormat": "cpr",
|
||||
"accounts": [{
|
||||
"id": "acct_duplicate_a",
|
||||
"accountId": "chatgpt-shared",
|
||||
"userId": "user-a",
|
||||
"token": "token-shared-a"
|
||||
"token": "opaque-duplicate-a"
|
||||
}, {
|
||||
"id": "acct_duplicate_b",
|
||||
"accountId": "chatgpt-shared",
|
||||
"userId": "user-a",
|
||||
"token": "token-shared-a"
|
||||
"token": "opaque-duplicate-b"
|
||||
}]
|
||||
}))
|
||||
.await
|
||||
@@ -724,8 +901,14 @@ async fn cpr_batch_allows_distinct_users_in_the_same_workspace() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oauth_import_extracts_tokens_without_parsing_source_projections() {
|
||||
let bundle = import_service(unused_import_refresher())
|
||||
async fn oauth_import_completes_profiles_from_account_source() {
|
||||
let bundle_source = account_source([authenticated_account(
|
||||
"chatgpt-bundle",
|
||||
"user-bundle",
|
||||
Some("remote-bundle@example.com"),
|
||||
Some("team"),
|
||||
)]);
|
||||
let bundle = import_service_with_source(unused_import_refresher(), Arc::clone(&bundle_source))
|
||||
.prepare_import_document(serde_json::json!({
|
||||
"exported_at": "2026-07-03T15:46:38.717Z",
|
||||
"proxies": [],
|
||||
@@ -734,10 +917,12 @@ async fn oauth_import_extracts_tokens_without_parsing_source_projections() {
|
||||
"platform": "openai",
|
||||
"type": "oauth",
|
||||
"credentials": {
|
||||
"access_token": "token-bundle",
|
||||
"access_token": "opaque-bundle",
|
||||
"refresh_token": "refresh-bundle",
|
||||
"chatgpt_account_id": "chatgpt-bundle",
|
||||
"chatgpt_user_id": "user-chatgpt-bundle"
|
||||
"chatgpt_account_id": "source-bundle",
|
||||
"chatgpt_user_id": "source-user-bundle",
|
||||
"email": "bundle-source@example.com",
|
||||
"plan_type": "team"
|
||||
},
|
||||
"concurrency": 3,
|
||||
"priority": 50
|
||||
@@ -745,48 +930,85 @@ async fn oauth_import_extracts_tokens_without_parsing_source_projections() {
|
||||
}))
|
||||
.await
|
||||
.expect("credential bundle import");
|
||||
let auth_document = import_service(unused_import_refresher())
|
||||
.prepare_import_document(serde_json::json!({
|
||||
"accounts": [{
|
||||
"type": "openai",
|
||||
"access_token": "token-auth-document",
|
||||
"refresh_token": "refresh-auth-document",
|
||||
"chatgpt_account_id": "chatgpt-auth-document",
|
||||
"chatgpt_user_id": "user-chatgpt-auth-document",
|
||||
"email": "auth-document@example.com",
|
||||
"label": "Auth document"
|
||||
}]
|
||||
}))
|
||||
.await
|
||||
.expect("auth document import");
|
||||
let auth_document_source = account_source([authenticated_account(
|
||||
"chatgpt-auth-document",
|
||||
"user-auth-document",
|
||||
Some("remote-auth-document@example.com"),
|
||||
Some("plus"),
|
||||
)]);
|
||||
let auth_document =
|
||||
import_service_with_source(unused_import_refresher(), Arc::clone(&auth_document_source))
|
||||
.prepare_import_document(serde_json::json!({
|
||||
"accounts": [{
|
||||
"type": "openai",
|
||||
"access_token": "opaque-auth-document",
|
||||
"refresh_token": "refresh-auth-document",
|
||||
"chatgpt_account_id": "source-auth-document",
|
||||
"chatgpt_user_id": "source-user-auth-document",
|
||||
"email": "auth-document@example.com",
|
||||
"label": "Auth document",
|
||||
"planType": "plus"
|
||||
}]
|
||||
}))
|
||||
.await
|
||||
.expect("auth document import");
|
||||
|
||||
for (prepared, expected) in [
|
||||
(&bundle, "chatgpt-bundle"),
|
||||
(&auth_document, "chatgpt-auth-document"),
|
||||
] {
|
||||
assert_eq!(prepared.accounts().len(), 1);
|
||||
assert_eq!(
|
||||
prepared.accounts()[0].account.upstream_account_id(),
|
||||
Some(expected)
|
||||
);
|
||||
assert!(
|
||||
prepared.accounts()[0]
|
||||
.account
|
||||
.id()
|
||||
.as_str()
|
||||
.starts_with("acct_")
|
||||
);
|
||||
}
|
||||
let bundle_account = &bundle.accounts()[0].account;
|
||||
assert_eq!(bundle.accounts().len(), 1);
|
||||
assert_eq!(bundle_account.upstream_account_id(), Some("chatgpt-bundle"));
|
||||
assert_eq!(bundle_account.upstream_user_id(), "user-bundle");
|
||||
assert_eq!(bundle_account.name(), "bundle@example.com");
|
||||
assert_eq!(bundle_account.email(), Some("remote-bundle@example.com"));
|
||||
assert_eq!(bundle_account.plan_type(), Some("team"));
|
||||
|
||||
let auth_document_account = &auth_document.accounts()[0].account;
|
||||
assert_eq!(auth_document.accounts().len(), 1);
|
||||
assert_eq!(
|
||||
auth_document_account.upstream_account_id(),
|
||||
Some("chatgpt-auth-document")
|
||||
);
|
||||
assert_eq!(
|
||||
auth_document_account.upstream_user_id(),
|
||||
"user-auth-document"
|
||||
);
|
||||
assert_eq!(auth_document_account.name(), "Auth document");
|
||||
assert_eq!(
|
||||
auth_document_account.email(),
|
||||
Some("remote-auth-document@example.com")
|
||||
);
|
||||
assert_eq!(auth_document_account.plan_type(), Some("plus"));
|
||||
assert_eq!(
|
||||
bundle_source
|
||||
.seen
|
||||
.lock()
|
||||
.expect("seen account-source access tokens")
|
||||
.as_slice(),
|
||||
["opaque-bundle"]
|
||||
);
|
||||
assert_eq!(
|
||||
auth_document_source
|
||||
.seen
|
||||
.lock()
|
||||
.expect("seen account-source access tokens")
|
||||
.as_slice(),
|
||||
["opaque-auth-document"]
|
||||
);
|
||||
assert!(!format!("{bundle:?} {auth_document:?}").contains("refresh-bundle"));
|
||||
assert!(!format!("{bundle:?} {auth_document:?}").contains("refresh-auth-document"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cliproxyapi_codex_auth_file_is_recognized_as_an_openai_auth_document() {
|
||||
let prepared = import_service(unused_import_refresher())
|
||||
let source = account_source([authenticated_account(
|
||||
"chatgpt-cpa",
|
||||
"user-cpa",
|
||||
Some("remote-cpa@example.com"),
|
||||
Some("pro"),
|
||||
)]);
|
||||
let prepared = import_service_with_source(unused_import_refresher(), Arc::clone(&source))
|
||||
.prepare_import_document(serde_json::json!({
|
||||
"type": "codex",
|
||||
"access_token": "token-cpa",
|
||||
"access_token": "opaque-cpa",
|
||||
"refresh_token": "refresh-cpa",
|
||||
"id_token": "id-cpa",
|
||||
"account_id": "chatgpt-cpa",
|
||||
@@ -799,6 +1021,8 @@ async fn cliproxyapi_codex_auth_file_is_recognized_as_an_openai_auth_document()
|
||||
assert_eq!(prepared.accounts().len(), 1);
|
||||
let account = &prepared.accounts()[0].account;
|
||||
assert_eq!(account.upstream_account_id(), Some("chatgpt-cpa"));
|
||||
assert_eq!(account.email(), Some("chatgpt-cpa@example.com"));
|
||||
assert_eq!(account.upstream_user_id(), "user-cpa");
|
||||
assert_eq!(account.email(), Some("remote-cpa@example.com"));
|
||||
assert_eq!(account.plan_type(), Some("pro"));
|
||||
assert_eq!(account.authentication_kind(), "oauth");
|
||||
}
|
||||
|
||||
@@ -1,3 +1,72 @@
|
||||
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;
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::{
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
@@ -13,8 +19,8 @@ use provider_openai::credential::token_client::{
|
||||
AuthorizationTokenSet,
|
||||
};
|
||||
use provider_openai::credential::{
|
||||
CodexAccountIdentityVerifier, CodexCredentialAdmin, CodexIdentityExpectation,
|
||||
CodexIdentityVerification, CodexIdentityVerificationError, CodexOAuthAdmin,
|
||||
CodexAuthenticatedAccount, CodexAuthenticatedAccountSource, CodexCredentialAdmin,
|
||||
CodexIdentityExpectation, CodexIdentityVerificationError, CodexOAuthAdmin,
|
||||
CodexOAuthAdminError, CodexOAuthAdminService, CodexOAuthPendingClaimOutcome,
|
||||
CodexOAuthPendingStore, CodexOAuthPendingStoreError, CodexOAuthSecret,
|
||||
CodexPendingAuthorization, CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential,
|
||||
@@ -24,6 +30,7 @@ use secrecy::{ExposeSecret, SecretString};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use url::Url;
|
||||
|
||||
use super::{OAuthRecoveryLogCapture, lock_oauth_recovery_log_capture};
|
||||
use crate::support::{MemoryAccountStore, profile, runtime_policy, secret};
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -158,51 +165,100 @@ impl AuthorizationCodeExchanger for Exchanger {
|
||||
id_token: None,
|
||||
},
|
||||
id_token: SecretString::from("oauth-id-token"),
|
||||
expires_in: Duration::from_secs(3_600),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct Verifier;
|
||||
struct NoRefreshTokenExchanger;
|
||||
|
||||
#[async_trait]
|
||||
impl CodexAccountIdentityVerifier for Verifier {
|
||||
async fn verify(
|
||||
impl AuthorizationCodeExchanger for NoRefreshTokenExchanger {
|
||||
async fn exchange_authorization_code(
|
||||
&self,
|
||||
_secret: &CodexOAuthSecret,
|
||||
_expectation: &CodexIdentityExpectation,
|
||||
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
|
||||
Err(CodexIdentityVerificationError::Rejected)
|
||||
}
|
||||
|
||||
async fn verify_authorization(
|
||||
&self,
|
||||
_secret: &CodexOAuthSecret,
|
||||
id_token: &SecretString,
|
||||
expected_nonce: &SecretString,
|
||||
_expectation: &CodexIdentityExpectation,
|
||||
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
|
||||
if id_token.expose_secret() != "oauth-id-token" || expected_nonce.expose_secret().len() < 16
|
||||
{
|
||||
return Err(CodexIdentityVerificationError::Rejected);
|
||||
grant: AuthorizationCodeGrant,
|
||||
) -> Result<AuthorizationTokenSet, AuthorizationCodeExchangeError> {
|
||||
if grant.code.expose_secret() != "authorization-code" {
|
||||
return Err(AuthorizationCodeExchangeError::Rejected);
|
||||
}
|
||||
Ok(CodexIdentityVerification::Complete(profile(
|
||||
"chatgpt-oauth",
|
||||
)))
|
||||
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()), Arc::new(Verifier))
|
||||
service_with_store(Arc::new(MemoryAccountStore::default()))
|
||||
}
|
||||
|
||||
fn service_with_store(
|
||||
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>,
|
||||
verifier: Arc<dyn CodexAccountIdentityVerifier>,
|
||||
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()),
|
||||
Arc::new(Exchanger),
|
||||
verifier,
|
||||
exchanger,
|
||||
account_source,
|
||||
store,
|
||||
runtime_policy(),
|
||||
CodexCredentialAdmin,
|
||||
@@ -283,8 +339,10 @@ async fn start_authorization_uses_pkce_nonce_and_fixed_official_redirect() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completion_returns_verified_core_account_without_writing_store() {
|
||||
let service = service();
|
||||
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
|
||||
@@ -307,25 +365,61 @@ async fn completion_returns_verified_core_account_without_writing_store() {
|
||||
assert_eq!(prepared.account.provider().as_str(), "openai");
|
||||
assert!(prepared.account.id().as_str().starts_with("acct_"));
|
||||
assert_eq!(
|
||||
prepared
|
||||
.credential
|
||||
.expose_to_provider()
|
||||
.get("access_token")
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some("oauth-access-token")
|
||||
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!(
|
||||
prepared
|
||||
.credential
|
||||
.expose_to_provider()
|
||||
.get("id_token")
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some("oauth-id-token")
|
||||
account_source.call_count(),
|
||||
1,
|
||||
"first OAuth authorization completes the account profile through the access-token source"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completion_accepts_unrelated_oauth_callback_parameters() {
|
||||
async fn authorization_code_profile_failure_is_upstream_unavailable() {
|
||||
let _capture_lock = lock_oauth_recovery_log_capture().await;
|
||||
let service = service_with_account_source(
|
||||
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)
|
||||
@@ -339,11 +433,11 @@ async fn completion_accepts_unrelated_oauth_callback_parameters() {
|
||||
owner_ref: owner_ref(),
|
||||
flow_id: started.flow_id,
|
||||
callback_url: SecretString::from(format!(
|
||||
"http://localhost:1455/auth/callback?code=authorization-code&state={state}&scope=openid%20profile&iss=https%3A%2F%2Fauth.openai.com"
|
||||
"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 with standard callback parameters");
|
||||
.expect("complete OAuth from a relayed callback URL");
|
||||
|
||||
assert!(matches!(
|
||||
prepared.credential,
|
||||
@@ -399,54 +493,6 @@ async fn completion_rejects_repeated_state_callback_parameter() {
|
||||
assert_eq!(error, CodexOAuthAdminError::CallbackRejected);
|
||||
}
|
||||
|
||||
struct UnavailableVerifier;
|
||||
|
||||
#[async_trait]
|
||||
impl CodexAccountIdentityVerifier for UnavailableVerifier {
|
||||
async fn verify(
|
||||
&self,
|
||||
_: &CodexOAuthSecret,
|
||||
_: &CodexIdentityExpectation,
|
||||
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
|
||||
Err(CodexIdentityVerificationError::Unavailable)
|
||||
}
|
||||
|
||||
async fn verify_authorization(
|
||||
&self,
|
||||
_: &CodexOAuthSecret,
|
||||
_: &SecretString,
|
||||
_: &SecretString,
|
||||
_: &CodexIdentityExpectation,
|
||||
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
|
||||
Err(CodexIdentityVerificationError::Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completion_classifies_unavailable_identity_verification_as_upstream_unavailable() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let service = service_with_store(store, Arc::new(UnavailableVerifier));
|
||||
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}"
|
||||
)),
|
||||
})
|
||||
.await
|
||||
.expect_err("identity verification unavailable");
|
||||
|
||||
assert_eq!(error, CodexOAuthAdminError::UpstreamUnavailable);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn callback_state_mismatch_releases_flow_for_retry() {
|
||||
let service = service();
|
||||
@@ -523,7 +569,7 @@ fn oauth_command_debug_redacts_owner_flow_and_callback() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reauthorization_uses_revision_advanced_after_start_for_prepared_rotation() {
|
||||
async fn reauthorization_preserves_existing_principal_without_identity_verification() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
store
|
||||
.seed_oauth_credential(ImportCodexOAuthCredential {
|
||||
@@ -538,13 +584,15 @@ async fn reauthorization_uses_revision_advanced_after_start_for_prepared_rotatio
|
||||
let original_account = store
|
||||
.account("acct_oauth_reauth")
|
||||
.expect("original account");
|
||||
let original_installation_id = store
|
||||
let original_runtime = store
|
||||
.repository()
|
||||
.load_runtime_credential(&original_account)
|
||||
.await
|
||||
.expect("original credential")
|
||||
.installation_id;
|
||||
let service = service_with_store(store.clone(), Arc::new(Verifier));
|
||||
.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"),
|
||||
@@ -580,16 +628,10 @@ async fn reauthorization_uses_revision_advanced_after_start_for_prepared_rotatio
|
||||
let runtime =
|
||||
provider_openai::credential::CodexCredentialCodec::decode(prepared.credential.credential())
|
||||
.expect("prepared credential");
|
||||
assert_eq!(
|
||||
runtime
|
||||
.authentication
|
||||
.oauth()
|
||||
.expect("OAuth credential")
|
||||
.access_token
|
||||
.expose_secret(),
|
||||
"oauth-access-token"
|
||||
);
|
||||
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")
|
||||
@@ -600,75 +642,31 @@ async fn reauthorization_uses_revision_advanced_after_start_for_prepared_rotatio
|
||||
);
|
||||
}
|
||||
|
||||
struct ReplacementIdentityVerifier;
|
||||
|
||||
#[async_trait]
|
||||
impl CodexAccountIdentityVerifier for ReplacementIdentityVerifier {
|
||||
async fn verify(
|
||||
&self,
|
||||
_: &CodexOAuthSecret,
|
||||
_: &CodexIdentityExpectation,
|
||||
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
|
||||
Err(CodexIdentityVerificationError::Rejected)
|
||||
}
|
||||
|
||||
async fn verify_authorization(
|
||||
&self,
|
||||
_: &CodexOAuthSecret,
|
||||
_: &SecretString,
|
||||
_: &SecretString,
|
||||
expectation: &CodexIdentityExpectation,
|
||||
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
|
||||
assert_eq!(expectation, &CodexIdentityExpectation::default());
|
||||
Ok(CodexIdentityVerification::Complete(profile(
|
||||
"chatgpt-different-owner",
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
struct RotatedPrincipalVerifier;
|
||||
|
||||
#[async_trait]
|
||||
impl CodexAccountIdentityVerifier for RotatedPrincipalVerifier {
|
||||
async fn verify(
|
||||
&self,
|
||||
_: &CodexOAuthSecret,
|
||||
_: &CodexIdentityExpectation,
|
||||
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
|
||||
Err(CodexIdentityVerificationError::Rejected)
|
||||
}
|
||||
|
||||
async fn verify_authorization(
|
||||
&self,
|
||||
_: &CodexOAuthSecret,
|
||||
_: &SecretString,
|
||||
_: &SecretString,
|
||||
_: &CodexIdentityExpectation,
|
||||
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
|
||||
let mut verified = profile("chatgpt-oauth");
|
||||
verified.oauth_subject = "rotated-oauth-subject".to_owned();
|
||||
verified.poid = None;
|
||||
Ok(CodexIdentityVerification::Complete(verified))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reauthorization_updates_changed_free_principal_for_same_account() {
|
||||
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_principal".to_owned(),
|
||||
name: "reauthorize".to_owned(),
|
||||
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_store(store, Arc::new(RotatedPrincipalVerifier));
|
||||
let service = service_with_exchanger(
|
||||
store,
|
||||
Arc::new(StaticAccountSource::default()),
|
||||
Arc::new(NoRefreshTokenExchanger),
|
||||
);
|
||||
let started = service
|
||||
.start_authorization(StartCodexOAuthAuthorization {
|
||||
mutation: reauthorization_mutation("request-rotated-principal", "acct_oauth_principal"),
|
||||
mutation: reauthorization_mutation(
|
||||
"request-reauth-fallback",
|
||||
"acct_oauth_reauth_fallback",
|
||||
),
|
||||
})
|
||||
.await
|
||||
.expect("start reauthorization");
|
||||
@@ -677,6 +675,10 @@ async fn reauthorization_updates_changed_free_principal_for_same_account() {
|
||||
.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
|
||||
.complete_authorization(CompleteCodexOAuthAuthorization {
|
||||
owner_ref: owner_ref(),
|
||||
@@ -687,70 +689,31 @@ async fn reauthorization_updates_changed_free_principal_for_same_account() {
|
||||
})
|
||||
.await
|
||||
.expect("complete reauthorization");
|
||||
drop(guard);
|
||||
let CompletedCodexOAuthCredential::Reauthorize(prepared) = prepared.credential else {
|
||||
panic!("expected prepared reauthorization");
|
||||
};
|
||||
let runtime =
|
||||
provider_openai::credential::CodexCredentialCodec::decode(prepared.credential.credential())
|
||||
.expect("prepared credential");
|
||||
let principal = runtime.principal.expect("OAuth principal");
|
||||
|
||||
assert_eq!(principal.oauth_subject, "rotated-oauth-subject");
|
||||
assert_eq!(principal.poid, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reauthorization_rebinds_target_record_to_authorized_identity() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
store
|
||||
.seed_oauth_credential(ImportCodexOAuthCredential {
|
||||
account_id: "acct_oauth_rebind".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 service = service_with_store(store, Arc::new(ReplacementIdentityVerifier));
|
||||
let started = service
|
||||
.start_authorization(StartCodexOAuthAuthorization {
|
||||
mutation: reauthorization_mutation("request-rebind", "acct_oauth_rebind"),
|
||||
})
|
||||
.await
|
||||
.expect("start reauthorization");
|
||||
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 completed = 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 identity replacement");
|
||||
let CompletedCodexOAuthCredential::Reauthorize(prepared) = completed.credential else {
|
||||
panic!("expected prepared reauthorization");
|
||||
};
|
||||
assert_eq!(
|
||||
prepared.credential.account_id().as_str(),
|
||||
"acct_oauth_rebind"
|
||||
runtime
|
||||
.authentication
|
||||
.oauth()
|
||||
.expect("OAuth credential")
|
||||
.refresh_token
|
||||
.as_ref()
|
||||
.expect("preserved refresh token")
|
||||
.expose_secret(),
|
||||
"rt-old-oauth-access"
|
||||
);
|
||||
let replacement = prepared
|
||||
.replacement_identity
|
||||
.as_ref()
|
||||
.expect("replacement identity");
|
||||
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!(
|
||||
replacement.upstream_account_id(),
|
||||
Some("chatgpt-different-owner")
|
||||
record["fields"]["access_token"],
|
||||
"oauth-access-without-refresh-token"
|
||||
);
|
||||
assert_eq!(
|
||||
replacement.upstream_user_id(),
|
||||
"user-chatgpt-different-owner"
|
||||
);
|
||||
assert_eq!(record["fields"]["refresh_token"], "rt-old-oauth-access");
|
||||
assert_eq!(record["fields"]["has_refresh_token"], true);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use provider_openai::credential::{
|
||||
};
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
|
||||
use super::{OAuthRecoveryLogCapture, lock_oauth_recovery_log_capture};
|
||||
use crate::support::{MemoryAccountStore, profile, runtime_policy, secret};
|
||||
|
||||
const OAUTH_BACKOFF_ATTEMPTS: u32 = 5;
|
||||
@@ -379,7 +380,88 @@ async fn successful_refresh_uses_redis_lease_and_cas_rotates_plaintext_tokens()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refreshed_identity_rejection_revision_fences_account_as_invalid() {
|
||||
async fn scheduled_refresh_preserves_existing_refresh_token_when_exchange_omits_one() {
|
||||
let (store, _, service) = setup(
|
||||
Ok(TokenPair {
|
||||
access_token: "scheduled-access-without-rotated-refresh".to_owned(),
|
||||
refresh_token: None,
|
||||
expires_in: Duration::from_secs(3_600),
|
||||
}),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
|
||||
let outcomes = service.refresh_due().await.expect("scheduled refresh");
|
||||
|
||||
assert!(matches!(
|
||||
outcomes.as_slice(),
|
||||
[CodexCredentialRefreshOutcome::Refreshed { account_id, .. }] if account_id == "acct_refresh"
|
||||
));
|
||||
let account = store.account("acct_refresh").expect("rotated account");
|
||||
assert_eq!(account.revision().get(), 2);
|
||||
let runtime = store
|
||||
.repository()
|
||||
.load_runtime_credential(&account)
|
||||
.await
|
||||
.expect("rotated credential");
|
||||
assert_eq!(
|
||||
runtime
|
||||
.authentication
|
||||
.oauth()
|
||||
.expect("OAuth credential")
|
||||
.access_token
|
||||
.expose_secret(),
|
||||
"scheduled-access-without-rotated-refresh"
|
||||
);
|
||||
assert_eq!(
|
||||
runtime
|
||||
.authentication
|
||||
.oauth()
|
||||
.expect("OAuth credential")
|
||||
.refresh_token
|
||||
.as_ref()
|
||||
.expect("preserved refresh token")
|
||||
.expose_secret(),
|
||||
"rt-old-access"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_refresh_response_is_logged_before_local_rejection() {
|
||||
let _capture_lock = lock_oauth_recovery_log_capture().await;
|
||||
let (_, _, service) = setup(
|
||||
Ok(TokenPair {
|
||||
access_token: "rejected-access".to_owned(),
|
||||
refresh_token: None,
|
||||
expires_in: Duration::ZERO,
|
||||
}),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let recovery_log = OAuthRecoveryLogCapture::default();
|
||||
let dispatch = recovery_log.dispatch();
|
||||
let guard = tracing::dispatcher::set_default(&dispatch);
|
||||
|
||||
let outcomes = service
|
||||
.refresh_due()
|
||||
.await
|
||||
.expect("refresh cycle completes");
|
||||
drop(guard);
|
||||
|
||||
assert!(matches!(
|
||||
outcomes.as_slice(),
|
||||
[CodexCredentialRefreshOutcome::Failed { account_id }] if account_id == "acct_refresh"
|
||||
));
|
||||
let record = recovery_log.recovery_record();
|
||||
assert_eq!(record["fields"]["operation"], "scheduled_refresh");
|
||||
assert_eq!(record["fields"]["account_id"], "acct_refresh");
|
||||
assert_eq!(record["fields"]["access_token"], "rejected-access");
|
||||
assert_eq!(record["fields"]["refresh_token"], "rt-old-access");
|
||||
assert_eq!(record["fields"]["has_refresh_token"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refreshed_tokens_persist_when_identity_verification_rejects() {
|
||||
let (store, _, _) = setup(Ok(success_tokens()), true).await;
|
||||
let service = CodexCredentialRefreshService::new(
|
||||
store.repository(),
|
||||
@@ -397,19 +479,31 @@ async fn refreshed_identity_rejection_revision_fences_account_as_invalid() {
|
||||
|
||||
assert!(matches!(
|
||||
outcomes.as_slice(),
|
||||
[CodexCredentialRefreshOutcome::Invalidated { .. }]
|
||||
[CodexCredentialRefreshOutcome::Refreshed {
|
||||
account_id,
|
||||
credential_revision: 2
|
||||
}] if account_id == "acct_refresh"
|
||||
));
|
||||
let account = store.account("acct_refresh").expect("refreshed account");
|
||||
assert_eq!(account.availability(), AccountAvailability::Ready);
|
||||
let runtime = store
|
||||
.repository()
|
||||
.load_runtime_credential(&account)
|
||||
.await
|
||||
.expect("refreshed credential");
|
||||
assert_eq!(
|
||||
store
|
||||
.account("acct_refresh")
|
||||
.expect("invalid account")
|
||||
.availability(),
|
||||
AccountAvailability::Invalid
|
||||
runtime
|
||||
.authentication
|
||||
.oauth()
|
||||
.expect("OAuth credential")
|
||||
.access_token
|
||||
.expose_secret(),
|
||||
"new-access"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unavailable_signature_verification_persists_refresh_backoff() {
|
||||
async fn refreshed_tokens_persist_when_identity_verification_is_unavailable() {
|
||||
let (store, _, _) = setup(Ok(success_tokens()), true).await;
|
||||
let service = CodexCredentialRefreshService::new(
|
||||
store.repository(),
|
||||
@@ -428,11 +522,10 @@ async fn unavailable_signature_verification_persists_refresh_backoff() {
|
||||
|
||||
assert!(matches!(
|
||||
outcomes.as_slice(),
|
||||
[CodexCredentialRefreshOutcome::Transient { .. }]
|
||||
[CodexCredentialRefreshOutcome::Refreshed { .. }]
|
||||
));
|
||||
let account = store.account("acct_refresh").expect("deferred account");
|
||||
let account = store.account("acct_refresh").expect("refreshed account");
|
||||
assert_eq!(account.revision().get(), 2);
|
||||
// JWKS/签名边界短暂不可用是瞬态:账号保持可用、不被永久失效,仅推进退避重试。
|
||||
assert_eq!(account.availability(), AccountAvailability::Ready);
|
||||
assert!(
|
||||
account
|
||||
@@ -442,7 +535,7 @@ async fn unavailable_signature_verification_persists_refresh_backoff() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unavailable_usage_preserves_rotated_tokens_and_persists_backoff() {
|
||||
async fn refreshed_tokens_do_not_require_identity_completion() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let signed = super::identity::signed_identity_fixture().await;
|
||||
let mut account_profile = profile("account-signed");
|
||||
@@ -476,7 +569,7 @@ async fn unavailable_usage_preserves_rotated_tokens_and_persists_backoff() {
|
||||
|
||||
assert!(matches!(
|
||||
outcomes.as_slice(),
|
||||
[CodexCredentialRefreshOutcome::Transient { .. }]
|
||||
[CodexCredentialRefreshOutcome::Refreshed { .. }]
|
||||
));
|
||||
let account = store.account("acct_signed_only").expect("rotated account");
|
||||
assert_eq!(account.revision().get(), 2);
|
||||
|
||||
@@ -75,6 +75,28 @@ async fn bounded_oauth_response_should_parse_lifetime_and_rotated_token() {
|
||||
assert_eq!(tokens.expires_in, Duration::from_secs(3600));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_response_with_blank_rotated_token_keeps_the_existing_token_eligible() {
|
||||
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": "access-rotated",
|
||||
"refresh_token": " ",
|
||||
"expires_in": 3600
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let tokens = client(&server)
|
||||
.refresh("refresh-initial")
|
||||
.await
|
||||
.expect("blank rotation field is treated as absent");
|
||||
|
||||
assert_eq!(tokens.refresh_token, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_should_exchange_the_official_form_fields() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
+9
-3
@@ -111,9 +111,10 @@ PostgreSQL 是账号、Client Key、运行设置、请求记录与审计的权
|
||||
Provider schema 以明文 JSON 保存于 PostgreSQL。Redis 只保存可重建、可过期的协调状态,例如
|
||||
会话亲和、lease、cooldown、OAuth pending flow 与套餐模型目录 cache。
|
||||
|
||||
备份必须包含 `.runtime/postgres`。若希望保留短期 Redis 状态、OpenAI 会话亲和和更新状态,
|
||||
同时备份 `.runtime/redis` 与 `.runtime/data`;后两者丢失不会造成账号 credential 丢失,但会使
|
||||
缓存和会话锚点重新建立。
|
||||
备份必须包含 `.runtime/postgres`。要保留 OpenAI OAuth 的 AT/RT 恢复能力,必须保持
|
||||
`host.logging.file.enabled: true` 并备份 `.runtime/logs`;该记录受普通日志的 `retention_days` 和
|
||||
`max_files` 约束。若希望保留短期 Redis 状态和会话锚点,也同时备份 `.runtime/redis` 与
|
||||
`.runtime/data`。
|
||||
|
||||
完整运行时、Provider、revision 与恢复边界见 [架构文档](../docs/architecture.md)。
|
||||
|
||||
@@ -202,6 +203,11 @@ Release 必须提供当前 OS/架构的 `codex-proxy-rs_<version>_<os>_<arch>.ta
|
||||
Debian 官方源补 `libpq5` 依赖,不引入第三方 APT 源,也不依赖运行时动态安装。
|
||||
- 备份暂存目录为 `/app/.runtime/data/backup-staging`,由 `.runtime/data` 卷持久化,权限
|
||||
`0700`(仅 `cpr` 用户可读写)。部署卷至少预留一个最大数据库归档的空间。
|
||||
- OpenAI OAuth 每次成功取得 AT/RT 后,都会在账号资料补全、过期时间计算和数据库写入前,以
|
||||
`openai_oauth_recovery` 结构化日志事件写入普通应用日志。事件含原始 AT/RT,完全沿用
|
||||
`.runtime/logs` 的按日、按大小分割和保留清理策略;文件日志开启时不会被普通日志级别筛掉。
|
||||
它遵循普通日志现有的非阻塞写入机制,不会阻断导入、授权或刷新。
|
||||
`.runtime/logs` 因此属于敏感数据,必须按现有普通日志的访问控制和加密备份策略处理。
|
||||
- S3/R2 存储、Cron 计划、保留策略与备份记录都保存在 PostgreSQL(`backup_settings` /
|
||||
`backup_records`),备份记录行在删除成功后硬删除,操作历史进入 `admin_audit_events`。
|
||||
|
||||
|
||||
+10
-8
@@ -122,7 +122,7 @@ OpenAI 路径保留客户端 Responses wire 语义:请求 body 的未知字段
|
||||
- xAI 从单账号 object 或 `accounts` 数组中提取 OAuth token;包装中的代理、并发、优先级等字段不参与认证;
|
||||
- xAI 批量导入逐条独立校验:失败条目跳过并记录日志,不中断其余条目,仅当没有任何条目成功时整个导入才报错;
|
||||
- xAI API Key 不是受支持的账号 credential;
|
||||
- 导入仍会通过 Provider 的身份或刷新链路校验,不能只凭文件外形写入账号。
|
||||
- 导入不会只凭文件外形写入账号;目标 Provider 使用认证材料完成必要的 token exchange 或已认证账号资料补全。
|
||||
|
||||
OpenAI 的 CPR 导出保持 OAuth 账号的既有字段;Agent Identity 账号则输出
|
||||
`authMode: "agentIdentity"`、`agentRuntimeId`、`agentPrivateKey` 和可选的
|
||||
@@ -171,20 +171,22 @@ OAuth start 使用:
|
||||
|
||||
### OpenAI 身份、额度与状态
|
||||
|
||||
- 导入会校验签名 access token 和稳定账号身份;`/wham/usage` 用于补全账号/套餐事实和额度,
|
||||
不是判断 token 是否有效的唯一依据。
|
||||
- OAuth 文件导入使用可用的 access token 调用 OpenAI 已认证账号接口,补全实际使用的账号 ID、用户 ID、
|
||||
邮箱和套餐;仅有 refresh token 时,先换取 access token 后执行同一补全。导入不要求 JWT payload 含有
|
||||
特定字段,不执行 JWKS 验签,也不将文件中的身份字段与上游资料比对。
|
||||
- 首次 OAuth 保留回调 `state`、PKCE 与官方 token exchange;换得 access token 后调用同一已认证账号接口
|
||||
补全账号资料。重新授权也保留这些回调保护,但只轮换目标账号的 token。回调地址只承载 `code`/`state`,
|
||||
不以 host/path 形式作为拒绝条件。
|
||||
- 账号文件导入和首次 OAuth 创建在 credential 提交后立即尝试一次额度观测。观测失败只记录告警,
|
||||
不回滚已提交的账号;重新授权只轮换目标账号 credential,不隐式等同于手工额度刷新。
|
||||
- 重新授权在 start 时绑定目标账号,complete 时重新读取目标的当前 credential revision,并验证上游
|
||||
account/user 身份没有换绑。Free token 缺少 `chatgpt_account_id` 时,后端使用目标账号 header 调用
|
||||
`/wham/usage` 交叉确认,而不是把该账号推断成 K12。
|
||||
不回滚已提交的账号;重新授权和手工或后台 RT 刷新只更新 token,不隐式等同于手工额度刷新,也不更新
|
||||
既有账号资料或 OAuth principal。
|
||||
- OAuth pending flow 先取得带过期时间的独占 claim,只有账号事务提交成功后才消费。失败会释放 claim,
|
||||
但上游 authorization code 本身通常只能交换一次;已完成过 token exchange 时应重新创建 OAuth flow。
|
||||
- `GET /accounts/quota` 只读取最后一次落库快照;`POST /accounts/quota/refresh` 才访问上游。access token
|
||||
已过期时,额度刷新要求先走 credential 刷新或重新授权,不会拿过期 token 探测额度。
|
||||
- 成功额度观测会 revision-fenced 写入 quota,并在 `active` 与 `quota_exhausted` 等额度所属状态间同步;
|
||||
不会清除 `expired`、`banned` 等 credential/封禁终态。额度接口的 401/403 也不足以判定 refresh token
|
||||
永久失效,credential 终态只由 OAuth refresh 或身份校验链路写入。
|
||||
永久失效,credential 终态只由 OAuth refresh 的明确永久错误写入。
|
||||
- 正常 Responses 请求会解析上游响应的 rate-limit headers,合并进同一 quota 快照并同步状态。Free、
|
||||
K12 等套餐共用该状态机;套餐只参与账号展示和按套餐隔离的模型目录 cache,不存在 K12 专属额度路径。
|
||||
- 账号页没有定时静默轮询。手工额度刷新完成后页面会重新读取该账号;请求驱动或后台任务产生的状态
|
||||
|
||||
@@ -120,11 +120,12 @@ credential 更新使用 `credential_revision` CAS。认证永久失败、封禁
|
||||
|
||||
`refresh_token_expires_at` 不是公共 SQL 列或 Core 权威状态。xAI 可在 `provider_credentials_json` 内保存它作为 Provider 私有提示;真正失效以 refresh endpoint 返回的永久错误为准。
|
||||
|
||||
OpenAI 导入、首次 OAuth 和重新授权都先验证签名 token 与稳定账号身份。重新授权的目标绑定只保存
|
||||
目标账号 ID,不接受或冻结客户端提供的 credential revision;complete 阶段重新读取目标账号及其当前
|
||||
revision,并以当前 revision 做最终 CAS。上游账号或用户身份发生换绑时拒绝提交;Free token 缺少
|
||||
账号 claim 时,用目标账号 header 下的 usage 身份做交叉确认。认证主体投影可以随同一上游账号的
|
||||
合法重新授权更新,但目标 upstream account/user 不得改变。
|
||||
OpenAI OAuth 导入先取得可用 access token(仅有 refresh token 时先交换),再调用 OpenAI 已认证账号
|
||||
接口补全实际使用的账号 ID、用户 ID、邮箱和套餐。首次 OAuth 在服务端 `state`、PKCE 与官方 token
|
||||
exchange 成功后执行同一补全。两条路径不执行 JWKS 验签,不要求 JWT payload 具备指定字段,也不将导入
|
||||
材料中的身份与上游资料做一致性比对。重新授权的目标绑定只保存目标账号 ID,不接受或冻结客户端提供的
|
||||
credential revision;complete 阶段重新读取目标账号及其当前 revision,并以当前 revision 做最终 CAS。
|
||||
重新授权和手工或后台 RT refresh 只轮换目标账号的 token,保留既有账号资料与 OAuth principal。
|
||||
|
||||
credential 与 quota 是两套状态机。主动 quota refresh 会拒绝过期 access token;quota 探测的
|
||||
401/403 不具备判定 refresh token 永久失效的证据。成功主动观测和正常推理响应中的 rate-limit header
|
||||
@@ -241,8 +242,8 @@ worker 不得通过导入 Admin use case 绕过边界,也不得维护第二份
|
||||
- FK、CAS、revision、recovery 使用真实 PostgreSQL 测试。
|
||||
- admission、lease、cooldown、OAuth pending 使用真实 Redis 测试。
|
||||
- 自动化测试不得发送真实对话或轮换生产 refresh token;仓库不提供破坏性 live fixture。
|
||||
- OpenAI identity 仅保留可选的外部 `CODEX_REAL_ACCOUNT_FILE` 官方 JWKS 签名检查,环境变量未设置时
|
||||
直接跳过;它不经过 selector/transport,也不是数据面验收。
|
||||
- 可选的外部 `CODEX_REAL_ACCOUNT_FILE` 官方 JWKS 签名检查仅用于显式测试,环境变量未设置时直接跳过;
|
||||
它不属于 OpenAI OAuth 导入、首次授权、重新授权或 RT refresh 流程,也不经过 selector/transport。
|
||||
|
||||
终态门禁:
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ const loadingIconSize: Record<ButtonSize, number> = {
|
||||
}
|
||||
|
||||
const classes = computed(() => [
|
||||
'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]',
|
||||
'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]',
|
||||
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,23 +110,32 @@ const labelClasses = computed(() => [
|
||||
>
|
||||
<span
|
||||
v-if="loading"
|
||||
class="inline-flex shrink-0 items-center justify-center leading-none"
|
||||
class="pointer-events-none absolute inset-0 inline-flex items-center justify-center leading-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<LoaderCircle
|
||||
:size="loadingIconSize[size]"
|
||||
class="block animate-spin origin-center will-change-transform transform-fill"
|
||||
class="block animate-spin origin-center [transform-box:view-box] will-change-transform"
|
||||
/>
|
||||
</span>
|
||||
<span v-if="$slots.icon && !loading" class="inline-flex shrink-0">
|
||||
<span
|
||||
v-if="$slots.icon"
|
||||
class="inline-flex shrink-0 transition-opacity duration-150"
|
||||
:class="loading ? 'opacity-0' : undefined"
|
||||
>
|
||||
<slot name="icon" />
|
||||
</span>
|
||||
<span v-if="!iconOnly && $slots.default" :class="labelClasses">
|
||||
<span
|
||||
v-if="!iconOnly && $slots.default"
|
||||
class="transition-opacity duration-150"
|
||||
:class="[labelClasses, loading ? 'opacity-0' : undefined]"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
<span
|
||||
v-if="iconOnly && !$slots.icon && !loading"
|
||||
class="inline-flex items-center justify-center"
|
||||
v-if="iconOnly && !$slots.icon"
|
||||
class="inline-flex items-center justify-center transition-opacity duration-150"
|
||||
:class="loading ? 'opacity-0' : undefined"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
|
||||
@@ -45,10 +45,11 @@ const quotaWindows = computed(() => orderedPanelQuotaWindows(props.account.quota
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="刷新额度"
|
||||
:loading="refreshing"
|
||||
:disabled="refreshing"
|
||||
@click="emit('refreshQuota', account.id)"
|
||||
>
|
||||
<RefreshCw class="size-3.5" :class="refreshing ? 'animate-spin' : undefined" />
|
||||
<RefreshCw class="size-3.5" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccountRow } from '../constants'
|
||||
import { KeyRound, LoaderCircle, MoreHorizontal, Power, RefreshCw, Trash2, Wifi } from '@lucide/vue'
|
||||
import { KeyRound, MoreHorizontal, Power, RefreshCw, Trash2, Wifi } from '@lucide/vue'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
@@ -52,51 +52,51 @@ const canReauthorize = computed(() => props.account.provider === 'openai')
|
||||
</template>
|
||||
|
||||
<template #default="{ close }">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8.5 w-full items-center gap-2 rounded-(--cp-input-radius-small) border-0 bg-transparent px-3 text-left text-[13px] leading-none font-emphasis text-(--cp-text-primary) transition-colors hover:bg-(--cp-default-bg-hover) disabled:cursor-not-allowed disabled:text-(--cp-disabled-text)"
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8.5! w-full justify-start! gap-2! rounded-(--cp-input-radius-small)! px-3! text-left text-[13px] leading-none! font-emphasis! text-(--cp-text-primary)!"
|
||||
:loading="testing"
|
||||
:disabled="testing"
|
||||
@click.stop="(close(), emit('test', account))"
|
||||
>
|
||||
<LoaderCircle v-if="testing" class="size-3.5 animate-spin text-(--cp-text-muted)" />
|
||||
<Wifi v-else class="size-3.5 text-(--cp-text-muted)" />
|
||||
<Wifi class="size-3.5 text-(--cp-text-muted)" />
|
||||
测试连接
|
||||
</button>
|
||||
<button
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="canRefreshToken"
|
||||
type="button"
|
||||
class="flex h-8.5 w-full items-center gap-2 rounded-(--cp-input-radius-small) border-0 bg-transparent px-3 text-left text-[13px] leading-none font-emphasis text-(--cp-text-primary) transition-colors hover:bg-(--cp-default-bg-hover) disabled:cursor-not-allowed disabled:text-(--cp-disabled-text)"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8.5! w-full justify-start! gap-2! rounded-(--cp-input-radius-small)! px-3! text-left text-[13px] leading-none! font-emphasis! text-(--cp-text-primary)!"
|
||||
:loading="refreshing"
|
||||
:disabled="refreshing"
|
||||
@click.stop="(close(), emit('refresh', account.id))"
|
||||
>
|
||||
<RefreshCw
|
||||
class="size-3.5 text-(--cp-text-muted)"
|
||||
:class="refreshing ? 'animate-spin' : undefined"
|
||||
/>
|
||||
<RefreshCw class="size-3.5 text-(--cp-text-muted)" />
|
||||
刷新 token
|
||||
</button>
|
||||
<button
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="canReauthorize"
|
||||
type="button"
|
||||
class="flex h-8.5 w-full items-center gap-2 rounded-(--cp-input-radius-small) border-0 bg-transparent px-3 text-left text-[13px] leading-none font-emphasis text-(--cp-text-primary) transition-colors hover:bg-(--cp-default-bg-hover)"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8.5! w-full justify-start! gap-2! rounded-(--cp-input-radius-small)! px-3! text-left text-[13px] leading-none! font-emphasis! text-(--cp-text-primary)!"
|
||||
@click.stop="(close(), emit('reauthorize', account))"
|
||||
>
|
||||
<KeyRound class="size-3.5 text-(--cp-text-muted)" />
|
||||
重新授权
|
||||
</button>
|
||||
<button
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8.5! w-full justify-start! gap-2! rounded-(--cp-input-radius-small)! px-3! text-left text-[13px] leading-none! font-emphasis! text-(--cp-text-primary)!"
|
||||
:loading="updatingStatus"
|
||||
type="button"
|
||||
class="flex h-8.5 w-full items-center gap-2 rounded-(--cp-input-radius-small) border-0 bg-transparent px-3 text-left text-[13px] leading-none font-emphasis text-(--cp-text-primary) transition-colors hover:bg-(--cp-default-bg-hover) disabled:cursor-not-allowed disabled:text-(--cp-disabled-text)"
|
||||
:disabled="updatingStatus"
|
||||
@click.stop="(close(), emit('toggleSchedule', account))"
|
||||
>
|
||||
<LoaderCircle
|
||||
v-if="updatingStatus"
|
||||
class="size-3.5 animate-spin text-(--cp-text-muted)"
|
||||
/>
|
||||
<Power v-else class="size-3.5 text-(--cp-text-muted)" />
|
||||
<Power class="size-3.5 text-(--cp-text-muted)" />
|
||||
{{ scheduleLabel }}
|
||||
</button>
|
||||
</BaseButton>
|
||||
</template>
|
||||
</BasePopover>
|
||||
</div>
|
||||
|
||||
@@ -46,10 +46,11 @@ const {
|
||||
class="text-(--cp-normal)"
|
||||
size="md"
|
||||
label="刷新概览"
|
||||
:loading="loading || refreshing"
|
||||
:disabled="loading || refreshing"
|
||||
@click="refresh"
|
||||
>
|
||||
<RefreshCw :size="19" :class="loading || refreshing ? 'animate-spin' : undefined" />
|
||||
<RefreshCw :size="19" />
|
||||
</BaseButton>
|
||||
</template>
|
||||
</BasePageHeader>
|
||||
|
||||
@@ -69,10 +69,11 @@ function showDetail(record: Awaited<ReturnType<typeof getOpsErrors>>['items'][nu
|
||||
variant="ghost"
|
||||
size="md"
|
||||
label="刷新错误明细"
|
||||
:loading="refreshing"
|
||||
:disabled="loading || refreshing"
|
||||
@click="refresh"
|
||||
>
|
||||
<RefreshCw class="size-4.5" :class="refreshing ? 'animate-spin' : undefined" />
|
||||
<RefreshCw class="size-4.5" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,10 +32,11 @@ const search = defineModel<string>('search', { required: true })
|
||||
variant="ghost"
|
||||
size="md"
|
||||
label="刷新使用记录"
|
||||
:loading="refreshing"
|
||||
:disabled="loading || refreshing"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<RefreshCw class="size-4.5" :class="refreshing ? 'animate-spin' : undefined" />
|
||||
<RefreshCw class="size-4.5" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1 +1 @@
|
||||
version: "3.1.10"
|
||||
version: "3.1.13"
|
||||
|
||||
Reference in New Issue
Block a user