fix(openai): simplify import and oauth validation

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-08-06 11:14:18 +08:00
co-authored by Codex
parent 28d024ceda
commit 30f528f084
8 changed files with 454 additions and 361 deletions
@@ -8,6 +8,10 @@ use std::fmt;
use std::sync::Arc;
use std::time::SystemTime;
use base64::{
Engine as _,
engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD},
};
use chrono::{DateTime, FixedOffset, Utc};
use gateway_core::engine::credential::{
AccountAvailability, CredentialCasUpdate, CredentialRevision, LoadedCredential,
@@ -25,7 +29,7 @@ use serde::Serialize;
use serde_json::Value;
use thiserror::Error;
use super::identity::{CodexAccountIdentityVerifier, CodexIdentityVerificationError};
use super::identity::CodexAccountIdentityVerifier;
use super::security::CodexCredentialCodec;
use super::token_client::{RefreshFailure, TokenRefresher};
use super::types::{
@@ -38,6 +42,9 @@ 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_IDENTITY_BYTES: usize = 2_048;
const MAX_IMPORT_EMAIL_BYTES: usize = 512;
const MAX_IMPORT_PLAN_BYTES: usize = 128;
pub struct ImportCodexOAuthCredential {
pub account_id: String,
@@ -112,6 +119,15 @@ enum ParsedCodexAuthentication {
},
}
#[derive(Default)]
struct UnverifiedOAuthClaims {
expires_at: Option<DateTime<Utc>>,
chatgpt_account_id: Option<String>,
chatgpt_user_id: Option<String>,
email: Option<String>,
plan_type: Option<String>,
}
impl fmt::Debug for ParsedCodexAuthentication {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@@ -553,14 +569,6 @@ impl CodexCredentialAdmin {
})
}
/// 完整 OAuth 授权按目标数据 ID 替换凭据与上游账号身份。
pub(crate) fn prepare_reauthorization(
&self,
input: RotateManagedCodexCredential,
) -> Result<PreparedCodexCredentialRotation, CodexCredentialAdminError> {
self.prepare_oauth_rotation(input, true)
}
fn prepare_oauth_rotation(
&self,
input: RotateManagedCodexCredential,
@@ -642,7 +650,6 @@ impl CodexCredentialAdmin {
/// 有状态的 Codex 手工刷新边界;消费调用方刚读取的当前 credential 并准备 CAS。
pub struct CodexCredentialAdminService {
refresher: Arc<dyn TokenRefresher>,
verifier: Arc<dyn CodexAccountIdentityVerifier>,
leases: Arc<dyn ProviderLeasePort>,
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
}
@@ -652,7 +659,6 @@ impl fmt::Debug for CodexCredentialAdminService {
formatter
.debug_struct("CodexCredentialAdminService")
.field("refresher", &"TokenRefresher")
.field("verifier", &"CodexAccountIdentityVerifier")
.field("leases", &"ProviderLeasePort")
.field("runtime_policy", &"ProviderRuntimePolicyPort")
.finish()
@@ -662,13 +668,12 @@ impl fmt::Debug for CodexCredentialAdminService {
impl CodexCredentialAdminService {
pub fn new(
refresher: Arc<dyn TokenRefresher>,
verifier: Arc<dyn CodexAccountIdentityVerifier>,
_verifier: Arc<dyn CodexAccountIdentityVerifier>,
leases: Arc<dyn ProviderLeasePort>,
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
) -> Self {
Self {
refresher,
verifier,
leases,
runtime_policy,
}
@@ -764,7 +769,7 @@ impl CodexCredentialAdminService {
Ok(prepared)
}
/// 从 OAuth token 对补全账号事实,并归一到唯一 `NewProviderAccount` 写入路径。
/// 对导入 token 做轻量 payload 检查,并归一到唯一 `NewProviderAccount` 写入路径。
pub async fn prepare_import_document(
&self,
payload: Value,
@@ -794,9 +799,12 @@ 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 (secret, access_token_expires_at) = self
.resolve_import_tokens(access_token.clone(), refresh_token.clone())
.await?;
let mut verified_account =
unverified_oauth_profile(&secret, &account_id, access_token_expires_at);
apply_import_metadata(&mut verified_account, &candidate);
if !upstream_identities.insert((
verified_account.chatgpt_user_id.clone(),
verified_account.chatgpt_account_id.clone(),
@@ -823,9 +831,11 @@ impl CodexCredentialAdminService {
)?;
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,
@@ -866,11 +876,11 @@ impl CodexCredentialAdminService {
Ok(PreparedCodexAccountImport { accounts })
}
async fn verify_import_tokens(
async fn resolve_import_tokens(
&self,
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)
@@ -880,21 +890,13 @@ impl CodexCredentialAdminService {
.map(normalize_bearer)
.filter(|token| !token.is_empty());
if let Some(access_token) = access_token {
validate_unverified_import_access_token(&access_token)?;
let secret = CodexOAuthSecret {
access_token: SecretString::from(access_token),
refresh_token: refresh_token.clone().map(SecretString::from),
access_token: SecretString::from(access_token.clone()),
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) => {}
}
return Ok((secret, unverified_access_token_expiry(&access_token)));
}
let refresh_token = refresh_token.ok_or(CodexCredentialAdminError::InvalidCredential)?;
let tokens = self
@@ -905,6 +907,7 @@ impl CodexCredentialAdminService {
if tokens.access_token.trim().is_empty() || tokens.expires_in.is_zero() {
return Err(CodexCredentialAdminError::InvalidCredential);
}
validate_unverified_import_access_token(&tokens.access_token)?;
let secret = CodexOAuthSecret {
access_token: SecretString::from(tokens.access_token),
// RT 未轮换时仍保留导入提供的 RT,保证本次补全不会丢失后续刷新能力。
@@ -914,12 +917,11 @@ 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))
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 prepare_agent_identity_import(
@@ -1046,15 +1048,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> {
@@ -1067,20 +1060,56 @@ 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: first_string(
value,
&[
"chatgpt_account_id",
"chatgptAccountId",
"account_id",
"accountId",
],
)
.or_else(|| {
first_string(
credentials,
&[
"chatgpt_account_id",
"chatgptAccountId",
"account_id",
"accountId",
],
)
}),
chatgpt_user_id: first_string(
value,
&["chatgpt_user_id", "chatgptUserId", "user_id", "userId"],
)
.or_else(|| {
first_string(
credentials,
&["chatgpt_user_id", "chatgptUserId", "user_id", "userId"],
)
}),
authentication: parse_oauth_import_tokens(value)?,
status: first_string(value, &["status"]),
})
}
fn parse_agent_identity_account(
value: &Value,
) -> Result<ParsedCodexImportAccount, CodexCredentialAdminError> {
@@ -1242,6 +1271,122 @@ fn normalize_bearer(value: &str) -> String {
.to_owned()
}
pub(crate) fn unverified_oauth_profile(
secret: &CodexOAuthSecret,
fallback_account_id: &str,
access_token_expires_at: Option<DateTime<Utc>>,
) -> CodexAccountProfile {
let claims =
unverified_access_token_claims(secret.access_token.expose_secret()).unwrap_or_default();
let fallback_identity = imported_identity(fallback_account_id);
let chatgpt_account_id = claims
.chatgpt_account_id
.as_deref()
.and_then(normalize_import_identity)
.unwrap_or_else(|| fallback_identity.clone());
let chatgpt_user_id = claims
.chatgpt_user_id
.as_deref()
.and_then(normalize_import_identity)
.unwrap_or_else(|| chatgpt_account_id.clone());
CodexAccountProfile {
email: claims
.email
.as_deref()
.and_then(|value| normalize_import_text(value, MAX_IMPORT_EMAIL_BYTES)),
oauth_subject: chatgpt_user_id.clone(),
poid: None,
chatgpt_account_id,
chatgpt_user_id,
plan_type: claims
.plan_type
.as_deref()
.and_then(|value| normalize_import_text(value, MAX_IMPORT_PLAN_BYTES)),
access_token_expires_at: access_token_expires_at.or(claims.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));
}
}
fn validate_unverified_import_access_token(token: &str) -> Result<(), CodexCredentialAdminError> {
let claims = unverified_access_token_claims(token)
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
if claims
.expires_at
.is_none_or(|expires_at| expires_at <= Utc::now())
|| claims
.chatgpt_account_id
.as_deref()
.is_none_or(|account_id| account_id.trim().is_empty())
{
return Err(CodexCredentialAdminError::InvalidCredential);
}
Ok(())
}
fn unverified_access_token_expiry(token: &str) -> Option<DateTime<Utc>> {
unverified_access_token_claims(token).and_then(|claims| claims.expires_at)
}
fn unverified_access_token_claims(token: &str) -> Option<UnverifiedOAuthClaims> {
let payload = token.split('.').nth(1)?;
let decoded = URL_SAFE_NO_PAD
.decode(payload)
.or_else(|_| URL_SAFE.decode(payload))
.ok()?;
let claims = serde_json::from_slice::<Value>(&decoded).ok()?;
let claims = claims.as_object()?;
let auth = claims
.get("https://api.openai.com/auth")
.and_then(Value::as_object);
let profile = claims
.get("https://api.openai.com/profile")
.and_then(Value::as_object);
let text = |object: Option<&serde_json::Map<String, Value>>, key| {
object
.and_then(|object| object.get(key))
.and_then(Value::as_str)
.map(str::to_owned)
};
Some(UnverifiedOAuthClaims {
expires_at: claims
.get("exp")
.and_then(Value::as_i64)
.and_then(|seconds| DateTime::<Utc>::from_timestamp(seconds, 0)),
chatgpt_account_id: text(auth, "chatgpt_account_id"),
chatgpt_user_id: text(auth, "chatgpt_user_id").or_else(|| text(profile, "chatgpt_user_id")),
email: text(profile, "email"),
plan_type: text(auth, "chatgpt_plan_type").or_else(|| text(profile, "chatgpt_plan_type")),
})
}
fn imported_identity(account_id: &str) -> String {
format!("imported_{}", account_id.trim_start_matches("acct_"))
}
fn normalize_import_identity(value: &str) -> Option<String> {
normalize_import_text(value, MAX_IMPORT_IDENTITY_BYTES)
}
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> {
@@ -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};
@@ -24,11 +28,9 @@ use uuid::Uuid;
use super::admin::{
CodexCredentialAdmin, CodexCredentialAdminError, ImportCodexOAuthCredential,
PreparedCodexCredentialRotation, RotateManagedCodexCredential, refresh_time,
};
use super::identity::{
CodexAccountIdentityVerifier, CodexIdentityExpectation, CodexIdentityVerificationError,
PreparedCodexCredentialRotation, refresh_time, unverified_oauth_profile,
};
use super::identity::CodexAccountIdentityVerifier;
use super::token_client::{
AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant,
OFFICIAL_CODEX_OAUTH_CLIENT_ID, OFFICIAL_CODEX_REDIRECT_URI,
@@ -344,7 +346,6 @@ pub trait CodexOAuthAdmin: Send + Sync {
pub struct CodexOAuthAdminService {
pending: Arc<dyn CodexOAuthPendingStore>,
exchanger: Arc<dyn AuthorizationCodeExchanger>,
verifier: Arc<dyn CodexAccountIdentityVerifier>,
store: Arc<dyn ProviderAccountStore>,
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
credentials: CodexCredentialAdmin,
@@ -396,7 +397,7 @@ impl CodexOAuthAdminService {
pub fn new(
pending: Arc<dyn CodexOAuthPendingStore>,
exchanger: Arc<dyn AuthorizationCodeExchanger>,
verifier: Arc<dyn CodexAccountIdentityVerifier>,
_verifier: Arc<dyn CodexAccountIdentityVerifier>,
store: Arc<dyn ProviderAccountStore>,
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
credentials: CodexCredentialAdmin,
@@ -404,7 +405,6 @@ impl CodexOAuthAdminService {
Self {
pending,
exchanger,
verifier,
store,
runtime_policy,
credentials,
@@ -487,7 +487,8 @@ impl CodexOAuthAdminService {
callback_url: &str,
) -> Result<(PendingAuthorizationMutation, CompletedCodexOAuthCredential), CodexOAuthAdminError>
{
let (mut secret, id_token) = self.exchange_pending(&pending, callback_url).await?;
let (mut secret, id_token, expires_in) =
self.exchange_pending(&pending, callback_url).await?;
let mutation = pending.mutation.clone();
let current = if let Some(target) = pending.reauthorization() {
let current = self
@@ -499,13 +500,10 @@ 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 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 +514,33 @@ 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 = unverified_oauth_profile(
&secret,
typed_account_id.as_str(),
Some(access_token_expires_at),
);
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 +657,8 @@ impl CodexOAuthAdminService {
&self,
pending: &CodexPendingAuthorization,
callback_url: &str,
) -> Result<(super::types::CodexOAuthSecret, SecretString), CodexOAuthAdminError> {
) -> 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,7 +674,7 @@ impl CodexOAuthAdminService {
})
.await
.map_err(map_exchange_error)?;
Ok((tokens.secret, tokens.id_token))
Ok((tokens.secret, tokens.id_token, tokens.expires_in))
}
}
@@ -699,27 +703,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,13 +809,6 @@ fn map_exchange_error(error: AuthorizationCodeExchangeError) -> CodexOAuthAdminE
}
}
fn map_identity_error(error: CodexIdentityVerificationError) -> CodexOAuthAdminError {
match error {
CodexIdentityVerificationError::Rejected => CodexOAuthAdminError::IdentityRejected,
CodexIdentityVerificationError::Unavailable => CodexOAuthAdminError::UpstreamUnavailable,
}
}
fn map_admin_error(_: CodexCredentialAdminError) -> CodexOAuthAdminError {
CodexOAuthAdminError::Credential
}
@@ -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()
}
}
@@ -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),
})
}
}
@@ -29,7 +29,7 @@ impl fmt::Debug for CodexOAuthSecret {
}
}
/// 已由官方 JWT/OIDC 与认证 usage 响应共同确认的完整账号投影。
/// OAuth credential 持久化时使用的账号投影。
#[derive(Clone)]
pub struct CodexAccountProfile {
pub email: Option<String>,
@@ -4,7 +4,7 @@ use std::time::Duration;
use async_trait::async_trait;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use chrono::{TimeZone, Utc};
use ed25519_dalek::SigningKey;
use ed25519_dalek::pkcs8::EncodePrivateKey as _;
@@ -46,6 +46,22 @@ fn encoded_credential(account_id: &str, token: &str) -> PlaintextCredential {
.expect("encode current credential")
}
fn unverified_import_access_token(account_id: &str, user_id: &str) -> String {
let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#);
let payload = URL_SAFE_NO_PAD.encode(
serde_json::to_vec(&serde_json::json!({
"sub": format!("subject-{user_id}"),
"exp": Utc::now().timestamp() + 3_600,
"https://api.openai.com/auth": {
"chatgpt_account_id": account_id,
"chatgpt_user_id": user_id,
},
}))
.expect("serialize unsigned import JWT"),
);
format!("{header}.{payload}.")
}
#[test]
fn prepare_import_returns_core_account_with_plaintext_json() {
let prepared = CodexCredentialAdmin
@@ -315,11 +331,10 @@ 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 =
unverified_import_access_token("chatgpt-acct_mixed_oauth", "user-acct_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");
@@ -564,35 +579,16 @@ async fn manual_refresh_missing_lease_fails_before_exchange() {
);
}
struct ImportVerifier;
struct RejectingImportVerifier;
#[async_trait]
impl CodexAccountIdentityVerifier for ImportVerifier {
impl CodexAccountIdentityVerifier for RejectingImportVerifier {
async fn verify(
&self,
secret: &provider_openai::credential::CodexOAuthSecret,
expectation: &CodexIdentityExpectation,
_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))
Err(CodexIdentityVerificationError::Rejected)
}
async fn verify_authorization(
@@ -611,7 +607,7 @@ pub(super) fn import_service(
) -> provider_openai::credential::CodexCredentialAdminService {
CodexCredentialAdminService::new(
refresher,
Arc::new(ImportVerifier),
Arc::new(RejectingImportVerifier),
Arc::new(ManualLeases {
requests: Mutex::new(Vec::new()),
drops: Arc::new(AtomicUsize::new(0)),
@@ -629,19 +625,20 @@ pub(super) fn unused_import_refresher() -> Arc<ManualRefresher> {
}
#[tokio::test]
async fn oauth_import_uses_only_tokens_and_verified_identity() {
async fn oauth_import_uses_token_identity_and_source_display_metadata() {
let refresher = unused_import_refresher();
let access_token = unverified_import_access_token("chatgpt-cpr", "user-token-cpr");
let prepared = import_service(refresher.clone())
.prepare_import_document(serde_json::json!({
"sourceFormat": "cpr",
"accounts": [{
"id": "acct_cpr_import",
"email": "cpr@example.com",
"accountId": "chatgpt-cpr",
"userId": "user-chatgpt-cpr",
"accountId": "source-account-cpr",
"userId": "source-user-cpr",
"label": "CPR account",
"planType": "pro",
"token": "Bearer token-cpr",
"token": format!("Bearer {access_token}"),
"refreshToken": "refresh-cpr",
"accessTokenExpiresAt": "2100-01-01T00:00:00+00:00",
"status": "disabled",
@@ -657,6 +654,10 @@ 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-token-cpr");
assert_eq!(account.account.name(), "CPR account");
assert_eq!(account.account.email(), Some("cpr@example.com"));
assert_eq!(account.account.plan_type(), Some("pro"));
let runtime = CodexCredentialCodec::decode(&account.credential).expect("credential");
assert_eq!(
runtime
@@ -665,13 +666,64 @@ 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());
}
#[tokio::test]
async fn refresh_token_only_import_persists_exchanged_tokens_without_identity_verification() {
let refreshed_access =
unverified_import_access_token("chatgpt-refreshed-import", "user-refreshed-import");
let expected_access = refreshed_access.clone();
let refresher = Arc::new(ManualRefresher {
outcome: Mutex::new(Some(Ok(TokenPair {
access_token: refreshed_access,
refresh_token: Some("rotated-import-refresh".to_owned()),
expires_in: Duration::from_secs(3_600),
}))),
seen: Mutex::new(Vec::new()),
});
let prepared = import_service(Arc::clone(&refresher))
.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");
let runtime = CodexCredentialCodec::decode(&account.credential).expect("credential");
let oauth = runtime.authentication.oauth().expect("OAuth credential");
assert_eq!(oauth.access_token.expose_secret(), expected_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"]
);
}
#[tokio::test]
async fn cpr_batch_allows_distinct_users_in_the_same_workspace() {
let shared_a = unverified_import_access_token("chatgpt-shared", "user-a");
let shared_b = unverified_import_access_token("chatgpt-shared", "user-b");
let prepared = import_service(unused_import_refresher())
.prepare_import_document(serde_json::json!({
"sourceFormat": "cpr",
@@ -679,12 +731,12 @@ async fn cpr_batch_allows_distinct_users_in_the_same_workspace() {
"id": "acct_shared_a",
"accountId": "chatgpt-shared",
"userId": "user-a",
"token": "token-shared-a"
"token": shared_a
}, {
"id": "acct_shared_b",
"accountId": "chatgpt-shared",
"userId": "user-b",
"token": "token-shared-b"
"token": shared_b
}]
}))
.await
@@ -698,6 +750,7 @@ async fn cpr_batch_allows_distinct_users_in_the_same_workspace() {
.all(|account| { account.account.upstream_account_id() == Some("chatgpt-shared") })
);
let duplicate_token = unverified_import_access_token("chatgpt-shared", "user-a");
let duplicate = import_service(unused_import_refresher())
.prepare_import_document(serde_json::json!({
"sourceFormat": "cpr",
@@ -705,12 +758,12 @@ async fn cpr_batch_allows_distinct_users_in_the_same_workspace() {
"id": "acct_duplicate_a",
"accountId": "chatgpt-shared",
"userId": "user-a",
"token": "token-shared-a"
"token": duplicate_token.clone()
}, {
"id": "acct_duplicate_b",
"accountId": "chatgpt-shared",
"userId": "user-a",
"token": "token-shared-a"
"token": duplicate_token
}]
}))
.await
@@ -719,7 +772,8 @@ async fn cpr_batch_allows_distinct_users_in_the_same_workspace() {
}
#[tokio::test]
async fn oauth_import_extracts_tokens_without_parsing_source_projections() {
async fn oauth_import_extracts_tokens_and_preserves_source_display_metadata() {
let bundle_token = unverified_import_access_token("chatgpt-token-bundle", "user-token-bundle");
let bundle = import_service(unused_import_refresher())
.prepare_import_document(serde_json::json!({
"exported_at": "2026-07-03T15:46:38.717Z",
@@ -729,10 +783,12 @@ async fn oauth_import_extracts_tokens_without_parsing_source_projections() {
"platform": "openai",
"type": "oauth",
"credentials": {
"access_token": "token-bundle",
"access_token": bundle_token,
"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
@@ -740,48 +796,62 @@ async fn oauth_import_extracts_tokens_without_parsing_source_projections() {
}))
.await
.expect("credential bundle import");
let auth_document_token =
unverified_import_access_token("chatgpt-token-auth-document", "user-token-auth-document");
let auth_document = import_service(unused_import_refresher())
.prepare_import_document(serde_json::json!({
"accounts": [{
"type": "openai",
"access_token": "token-auth-document",
"access_token": auth_document_token,
"refresh_token": "refresh-auth-document",
"chatgpt_account_id": "chatgpt-auth-document",
"chatgpt_user_id": "user-chatgpt-auth-document",
"chatgpt_account_id": "source-auth-document",
"chatgpt_user_id": "source-user-auth-document",
"email": "auth-document@example.com",
"label": "Auth document"
"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-token-bundle")
);
assert_eq!(bundle_account.upstream_user_id(), "user-token-bundle");
assert_eq!(bundle_account.name(), "bundle@example.com");
assert_eq!(bundle_account.email(), Some("bundle-source@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-token-auth-document")
);
assert_eq!(
auth_document_account.upstream_user_id(),
"user-token-auth-document"
);
assert_eq!(auth_document_account.name(), "Auth document");
assert_eq!(
auth_document_account.email(),
Some("auth-document@example.com")
);
assert_eq!(auth_document_account.plan_type(), Some("plus"));
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 access_token = unverified_import_access_token("chatgpt-token-cpa", "user-token-cpa");
let prepared = import_service(unused_import_refresher())
.prepare_import_document(serde_json::json!({
"type": "codex",
"access_token": "token-cpa",
"access_token": access_token,
"refresh_token": "refresh-cpa",
"id_token": "id-cpa",
"account_id": "chatgpt-cpa",
@@ -793,7 +863,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_account_id(), Some("chatgpt-token-cpa"));
assert_eq!(account.upstream_user_id(), "user-token-cpa");
assert_eq!(account.email(), Some("cpa@example.com"));
assert_eq!(account.authentication_kind(), "oauth");
}
@@ -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};
@@ -158,19 +164,30 @@ impl AuthorizationCodeExchanger for Exchanger {
id_token: None,
},
id_token: SecretString::from("oauth-id-token"),
expires_in: Duration::from_secs(3_600),
})
}
}
struct Verifier;
#[derive(Default)]
struct RejectingVerifier {
calls: AtomicUsize,
}
impl RejectingVerifier {
fn call_count(&self) -> usize {
self.calls.load(Ordering::Relaxed)
}
}
#[async_trait]
impl CodexAccountIdentityVerifier for Verifier {
impl CodexAccountIdentityVerifier for RejectingVerifier {
async fn verify(
&self,
_secret: &CodexOAuthSecret,
_expectation: &CodexIdentityExpectation,
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
self.calls.fetch_add(1, Ordering::Relaxed);
Err(CodexIdentityVerificationError::Rejected)
}
@@ -181,18 +198,17 @@ impl CodexAccountIdentityVerifier for Verifier {
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);
}
Ok(CodexIdentityVerification::Complete(profile(
"chatgpt-oauth",
)))
let _ = (id_token, expected_nonce);
self.calls.fetch_add(1, Ordering::Relaxed);
Err(CodexIdentityVerificationError::Rejected)
}
}
fn service() -> CodexOAuthAdminService {
service_with_store(Arc::new(MemoryAccountStore::default()), Arc::new(Verifier))
service_with_store(
Arc::new(MemoryAccountStore::default()),
Arc::new(RejectingVerifier::default()),
)
}
fn service_with_store(
@@ -283,8 +299,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_prepares_core_account_without_identity_verification() {
let store = Arc::new(MemoryAccountStore::default());
let verifier = Arc::new(RejectingVerifier::default());
let service = service_with_store(store, verifier.clone());
let started = started(&service).await;
let url = Url::parse(&started.authorization_url).expect("authorization URL");
let state = url
@@ -322,10 +340,11 @@ async fn completion_returns_verified_core_account_without_writing_store() {
.and_then(serde_json::Value::as_str),
Some("oauth-id-token")
);
assert_eq!(verifier.call_count(), 0);
}
#[tokio::test]
async fn completion_accepts_unrelated_oauth_callback_parameters() {
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 +358,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,7 +418,16 @@ async fn completion_rejects_repeated_state_callback_parameter() {
assert_eq!(error, CodexOAuthAdminError::CallbackRejected);
}
struct UnavailableVerifier;
#[derive(Default)]
struct UnavailableVerifier {
calls: AtomicUsize,
}
impl UnavailableVerifier {
fn call_count(&self) -> usize {
self.calls.load(Ordering::Relaxed)
}
}
#[async_trait]
impl CodexAccountIdentityVerifier for UnavailableVerifier {
@@ -408,6 +436,7 @@ impl CodexAccountIdentityVerifier for UnavailableVerifier {
_: &CodexOAuthSecret,
_: &CodexIdentityExpectation,
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
self.calls.fetch_add(1, Ordering::Relaxed);
Err(CodexIdentityVerificationError::Unavailable)
}
@@ -418,14 +447,16 @@ impl CodexAccountIdentityVerifier for UnavailableVerifier {
_: &SecretString,
_: &CodexIdentityExpectation,
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
self.calls.fetch_add(1, Ordering::Relaxed);
Err(CodexIdentityVerificationError::Unavailable)
}
}
#[tokio::test]
async fn completion_classifies_unavailable_identity_verification_as_upstream_unavailable() {
async fn completion_ignores_unavailable_identity_verifier() {
let store = Arc::new(MemoryAccountStore::default());
let service = service_with_store(store, Arc::new(UnavailableVerifier));
let verifier = Arc::new(UnavailableVerifier::default());
let service = service_with_store(store, verifier.clone());
let started = started(&service).await;
let state = Url::parse(&started.authorization_url)
.expect("authorization URL")
@@ -433,7 +464,7 @@ async fn completion_classifies_unavailable_identity_verification_as_upstream_una
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
.expect("state");
let error = service
let prepared = service
.complete_authorization(CompleteCodexOAuthAuthorization {
owner_ref: owner_ref(),
flow_id: started.flow_id,
@@ -442,9 +473,13 @@ async fn completion_classifies_unavailable_identity_verification_as_upstream_una
)),
})
.await
.expect_err("identity verification unavailable");
.expect("complete OAuth without identity verification");
assert_eq!(error, CodexOAuthAdminError::UpstreamUnavailable);
assert!(matches!(
prepared.credential,
CompletedCodexOAuthCredential::Create(_)
));
assert_eq!(verifier.call_count(), 0);
}
#[tokio::test]
@@ -523,7 +558,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 +573,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 verifier = Arc::new(RejectingVerifier::default());
let service = service_with_store(store.clone(), verifier.clone());
let started = service
.start_authorization(StartCodexOAuthAuthorization {
mutation: reauthorization_mutation("request-reauth", "acct_oauth_reauth"),
@@ -590,6 +627,9 @@ async fn reauthorization_uses_revision_advanced_after_start_for_prepared_rotatio
"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!(verifier.call_count(), 0);
assert_eq!(
store
.account("acct_oauth_reauth")
@@ -599,158 +639,3 @@ async fn reauthorization_uses_revision_advanced_after_start_for_prepared_rotatio
2
);
}
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() {
let store = Arc::new(MemoryAccountStore::default());
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: "acct_oauth_principal".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(RotatedPrincipalVerifier));
let started = service
.start_authorization(StartCodexOAuthAuthorization {
mutation: reauthorization_mutation("request-rotated-principal", "acct_oauth_principal"),
})
.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 prepared = service
.complete_authorization(CompleteCodexOAuthAuthorization {
owner_ref: owner_ref(),
flow_id: started.flow_id,
callback_url: SecretString::from(format!(
"http://localhost:1455/auth/callback?code=authorization-code&state={state}"
)),
})
.await
.expect("complete reauthorization");
let CompletedCodexOAuthCredential::Reauthorize(prepared) = prepared.credential else {
panic!("expected prepared reauthorization");
};
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"
);
let replacement = prepared
.replacement_identity
.as_ref()
.expect("replacement identity");
assert_eq!(
replacement.upstream_account_id(),
Some("chatgpt-different-owner")
);
assert_eq!(
replacement.upstream_user_id(),
"user-chatgpt-different-owner"
);
}
+6 -6
View File
@@ -171,13 +171,13 @@ OAuth start 使用:
### OpenAI 身份、额度与状态
- 导入会校验签名 access token 和稳定账号身份;`/wham/usage` 用于补全账号/套餐事实和额度,
不是判断 token 是否有效的唯一依据。
- 文件导入只轻量解析 access token 的未验签 JWT payload:要求 `exp` 未过期且存在
`chatgpt_account_id`;不调用 JWKS 或 `/wham/usage`,也不做账号身份一致性比对。
- 首次 OAuth 和重新授权保留回调 `state`、PKCE 与官方 token exchange。回调地址只承载 `code`/`state`,
不以 host/path 形式作为拒绝条件。
- 账号文件导入和首次 OAuth 创建在 credential 提交后立即尝试一次额度观测。观测失败只记录告警,
不回滚已提交的账号;重新授权只轮换目标账号 credential,不隐式等同于手工额度刷新。
- 重新授权在 start 时绑定目标账号,complete 时重新读取目标的当前 credential revision,并验证上游
account/user 身份没有换绑。Free token 缺少 `chatgpt_account_id` 时,后端使用目标账号 header 调用
`/wham/usage` 交叉确认,而不是把该账号推断成 K12。
不回滚已提交的账号;重新授权只轮换目标账号 credential,不隐式等同于手工额度刷新,也不更新既有
upstream account/user 或 OAuth principal。
- OAuth pending flow 先取得带过期时间的独占 claim,只有账号事务提交成功后才消费。失败会释放 claim,
但上游 authorization code 本身通常只能交换一次;已完成过 token exchange 时应重新创建 OAuth flow。
- `GET /accounts/quota` 只读取最后一次落库快照;`POST /accounts/quota/refresh` 才访问上游。access token
+5 -5
View File
@@ -120,11 +120,11 @@ 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 导入只对 access token 做轻量、未验签的 JWT payload 检查:payload 可解析、`exp` 未过期且
带有 `chatgpt_account_id`。首次 OAuth 和重新授权保留服务端 `state`、PKCE 与官方 token exchange;
它们不再调用 JWKS、usage 或账号身份一致性校验。重新授权的目标绑定只保存目标账号 ID,不接受或
冻结客户端提供的 credential revision;complete 阶段重新读取目标账号及其当前 revision,并以当前
revision 做最终 CAS。重新授权只轮换目标账号的 token,保留既有 upstream account/user 与 principal。
credential 与 quota 是两套状态机。主动 quota refresh 会拒绝过期 access token;quota 探测的
401/403 不具备判定 refresh token 永久失效的证据。成功主动观测和正常推理响应中的 rate-limit header