fix(openai): align account import with official token claims

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-08-12 23:51:26 +08:00
co-authored by Codex
parent 2b8b3de0e4
commit 6cad2bb6a1
15 changed files with 540 additions and 97 deletions
@@ -51,6 +51,7 @@ const MAX_NAME_BYTES: usize = 512;
const MAX_IMPORT_DATA_BYTES: usize = 64 * 1024 * 1024;
const MAX_ACCESS_TOKEN_BYTES: usize = 16 * 1024;
const MAX_REFRESH_TOKEN_BYTES: usize = 64 * 1024;
const MAX_ID_TOKEN_BYTES: usize = 16 * 1024;
const MAX_CALLBACK_URL_BYTES: usize = 64 * 1024;
const MAX_ACCOUNT_DELETE_BATCH: usize = 200;
@@ -760,6 +761,7 @@ pub struct RotateAccountRequest {
pub account_id: String,
pub access_token: String,
pub refresh_token: Option<String>,
pub id_token: Option<String>,
}
impl RotateAccountRequest {
@@ -768,7 +770,11 @@ impl RotateAccountRequest {
return Err(WireValidationError::new("provider"));
}
require_account_id(&self.account_id, "accountId")?;
validate_oauth_material(&self.access_token, self.refresh_token.as_deref())
validate_oauth_material(
&self.access_token,
self.refresh_token.as_deref(),
self.id_token.as_deref(),
)
}
fn into_command(
@@ -782,6 +788,10 @@ impl RotateAccountRequest {
"refresh_token".to_owned(),
self.refresh_token.map_or(Value::Null, Value::String),
);
material.insert(
"id_token".to_owned(),
self.id_token.map_or(Value::Null, Value::String),
);
Ok(RotateCredential {
mutation: CredentialMutation {
context,
@@ -1681,6 +1691,7 @@ fn require_text(
fn validate_oauth_material(
access_token: &str,
refresh_token: Option<&str>,
id_token: Option<&str>,
) -> Result<(), WireValidationError> {
if access_token.len() > MAX_ACCESS_TOKEN_BYTES
|| !valid_visible_ascii(access_token)
@@ -1695,6 +1706,13 @@ fn validate_oauth_material(
}) {
return Err(WireValidationError::new("refreshToken"));
}
if id_token.is_some_and(|token| {
token.len() > MAX_ID_TOKEN_BYTES
|| !valid_visible_ascii(token)
|| !valid_compact_jwt_shape(token)
}) {
return Err(WireValidationError::new("idToken"));
}
Ok(())
}
@@ -217,7 +217,8 @@ mod actions {
"provider": "openai",
"accountId": "acct_1",
"accessToken": "header.payload.signature",
"refreshToken": "refresh-token"
"refreshToken": "refresh-token",
"idToken": "id-header.id-payload.id-signature"
}))
.expect("decode rotation");
assert!(rotation.validate().is_ok());
+11 -2
View File
@@ -368,7 +368,15 @@ impl ProviderAdmin for OpenAiAdminProvider {
if !account_matches_record(&current.account, &command.account) {
return Err(provider_admin_error(ProviderAdminErrorKind::Conflict));
}
let secret = rotation_secret(command.provider_material)?;
let mut secret = rotation_secret(command.provider_material)?;
if secret.id_token.is_none() {
let runtime = CodexCredentialCodec::decode(&current.credential)
.map_err(|_| provider_admin_error(ProviderAdminErrorKind::Invalid))?;
secret.id_token = runtime
.authentication
.oauth()
.and_then(|oauth| oauth.id_token.clone());
}
let prepared = CodexCredentialAdmin
.prepare_refreshed_oauth_rotation(current, secret, None, None)
.map_err(map_credential_admin_error)?;
@@ -574,6 +582,7 @@ fn prepared_rotation(
struct RotationDocument {
access_token: String,
refresh_token: Option<String>,
id_token: Option<String>,
}
fn rotation_secret(document: ProviderDocument) -> Result<CodexOAuthSecret, ProviderAdminError> {
@@ -583,7 +592,7 @@ fn rotation_secret(document: ProviderDocument) -> Result<CodexOAuthSecret, Provi
Ok(CodexOAuthSecret {
access_token: SecretString::from(document.access_token),
refresh_token: document.refresh_token.map(SecretString::from),
id_token: None,
id_token: document.id_token.map(SecretString::from),
})
}
@@ -31,14 +31,13 @@ use super::types::{
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile,
CodexAgentIdentityAuthMode, CodexAgentIdentityCredentialData, CodexCredentialData,
CodexCredentialPrincipal, CodexOAuthMetadata, CodexOAuthSecret, parse_access_token_expiration,
parse_chatgpt_jwt_claims,
};
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,
@@ -51,8 +50,8 @@ pub struct ImportCodexOAuthCredential {
/// OAuth credential 的最小创建输入。
///
/// metadata 来自 ID token 的本地 payload 解码,或导入文档的展示字段;不包含
/// 签名、issuer、audience、nonce 或 access/refresh token 验证。
/// metadata 来自 ID token/access token 的官方本地 payload 解析;不包含签名、
/// issuer、audience、nonce 或 token 验证。
pub(crate) struct UnresolvedCodexOAuthCredential {
pub(crate) account_id: String,
pub(crate) name: String,
@@ -270,8 +269,9 @@ struct CodexCprExportCommon {
struct CodexCprOAuthExportAccount {
#[serde(flatten)]
common: CodexCprExportCommon,
token: String,
access_token: String,
refresh_token: Option<String>,
id_token: Option<String>,
access_token_expires_at: Option<String>,
}
@@ -546,8 +546,9 @@ impl CodexCredentialAdmin {
}
CodexCprExportAccount::OAuth(CodexCprOAuthExportAccount {
common,
token: data.access_token,
access_token: data.access_token,
refresh_token: data.refresh_token,
id_token: data.id_token,
access_token_expires_at: account
.access_token_expires_at()
.map(DateTime::<Utc>::from)
@@ -794,23 +795,37 @@ impl CodexCredentialAdminService {
.refresh(refresh_token.expose_secret())
.await
.map_err(map_refresh_failure)?;
let access_token_rotated = tokens.access_token.is_some();
let secret = CodexOAuthSecret {
access_token: SecretString::from(tokens.access_token),
access_token: tokens
.access_token
.map(SecretString::from)
.unwrap_or_else(|| oauth.access_token.clone()),
refresh_token: tokens
.refresh_token
.map(SecretString::from)
.or_else(|| oauth.refresh_token.clone()),
id_token: oauth.id_token.clone(),
id_token: tokens
.id_token
.map(SecretString::from)
.or_else(|| oauth.id_token.clone()),
};
Self::record_recovery_log(
CodexOAuthRecoveryOperation::ManualRefresh,
Some(account_id.as_str()),
&secret,
);
let access_token_expires_at = tokens
.expires_in
.and_then(|expires_in| SystemTime::now().checked_add(expires_in))
.map(DateTime::<Utc>::from);
let access_token_expires_at = if access_token_rotated {
tokens
.expires_in
.and_then(|expires_in| SystemTime::now().checked_add(expires_in))
.map(DateTime::<Utc>::from)
} else {
current
.account
.access_token_expires_at()
.map(DateTime::<Utc>::from)
};
// 正常预刷新由 worker 读取当时的 runtime policy 动态判断;这里仅清除
// 之前瞬态失败留下的 retry-not-before。
let next_refresh_at = None;
@@ -829,8 +844,8 @@ impl CodexCredentialAdminService {
/// 归一导入 OAuth/agent-identity 凭据到唯一 `NewProviderAccount` 写入路径。
///
/// OAuth 导入只要取得 access token(直接提供或 RT exchange)即创建 Unknown
/// 账号;资格资料由后续 worker 异步补齐。
/// OAuth 导入先取得 access token(直接提供或 RT exchange),再按官方
/// `parse_chatgpt_jwt_claims` 从 ID token/access token 本地投影账号资料。
pub async fn prepare_import_document(
&self,
payload: Value,
@@ -870,16 +885,28 @@ impl CodexCredentialAdminService {
id_token.clone(),
)
.await?;
// 导入材料按管理员输入原样保存;ID token 仅尽力补展示字段,
// 解析失败不阻塞 AT/RT 入库。
let mut metadata = secret
// 与官方 `parse_chatgpt_jwt_claims` 一致:先读 ID token,缺失字段
// 再由 access token 补齐。解析失败不阻塞 token 入库。
let id_metadata = secret
.id_token
.as_ref()
.and_then(|token| {
super::types::parse_id_token_metadata(token.expose_secret()).ok()
})
.and_then(|token| parse_chatgpt_jwt_claims(token.expose_secret()).ok())
.unwrap_or_default();
apply_import_metadata(&mut metadata, &candidate);
let access_metadata =
parse_chatgpt_jwt_claims(secret.access_token.expose_secret())
.unwrap_or_default();
let metadata = CodexOAuthMetadata {
email: id_metadata.email.or(access_metadata.email),
chatgpt_plan_type: id_metadata
.chatgpt_plan_type
.or(access_metadata.chatgpt_plan_type),
chatgpt_user_id: id_metadata
.chatgpt_user_id
.or(access_metadata.chatgpt_user_id),
chatgpt_account_id: id_metadata
.chatgpt_account_id
.or(access_metadata.chatgpt_account_id),
};
CodexCredentialAdmin.prepare_unresolved_oauth(
UnresolvedCodexOAuthCredential {
account_id,
@@ -961,14 +988,17 @@ impl CodexCredentialAdminService {
.refresh(&refresh_token)
.await
.map_err(map_refresh_failure)?;
let access_token = tokens
.access_token
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
let secret = CodexOAuthSecret {
access_token: SecretString::from(tokens.access_token),
access_token: SecretString::from(access_token),
// RT 未轮换时仍保留导入提供的 RT,保证本次补全不会丢失后续刷新能力。
refresh_token: tokens
.refresh_token
.map(SecretString::from)
.or_else(|| Some(SecretString::from(refresh_token))),
id_token,
id_token: tokens.id_token.map(SecretString::from).or(id_token),
};
Self::record_recovery_log(
CodexOAuthRecoveryOperation::ImportRefreshToken,
@@ -1221,9 +1251,9 @@ fn parse_oauth_import_tokens(
Value::Object(object) => {
for (key, value) in object {
let token = match key.as_str() {
"access_token" | "accessToken" | "at" | "token" => &mut access_token,
"refresh_token" | "refreshToken" | "rt" => &mut refresh_token,
"id_token" | "idToken" => &mut id_token,
"accessToken" => &mut access_token,
"refreshToken" => &mut refresh_token,
"idToken" => &mut id_token,
_ => {
pending.push(value);
continue;
@@ -1288,27 +1318,6 @@ fn first_string(value: &Value, keys: &[&str]) -> Option<String> {
.map(str::to_owned)
}
fn apply_import_metadata(metadata: &mut CodexOAuthMetadata, candidate: &ParsedCodexImportAccount) {
if metadata.email.is_none() {
metadata.email = candidate
.email
.as_deref()
.and_then(|value| normalize_import_text(value, MAX_IMPORT_EMAIL_BYTES));
}
if metadata.chatgpt_plan_type.is_none() {
metadata.chatgpt_plan_type = candidate
.plan_type
.as_deref()
.and_then(|value| normalize_import_text(value, MAX_IMPORT_PLAN_BYTES));
}
}
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())
}
struct ImportedCprAccountFacts {
enabled: bool,
credential_state: CredentialState,
@@ -35,7 +35,7 @@ use super::token_client::{
AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant,
OFFICIAL_CODEX_OAUTH_CLIENT_ID, OFFICIAL_CODEX_REDIRECT_URI,
};
use super::types::parse_id_token_metadata;
use super::types::parse_chatgpt_jwt_claims;
const AUTHORIZATION_ENDPOINT: &str = "https://auth.openai.com/oauth/authorize";
const AUTHORIZATION_SCOPE: &str = "openid profile email offline_access";
@@ -504,7 +504,7 @@ impl CodexOAuthAdminService {
let access_token_expires_at = expires_in
.and_then(|expires_in| SystemTime::now().checked_add(expires_in))
.map(DateTime::<Utc>::from);
let metadata = parse_id_token_metadata(id_token.expose_secret())
let metadata = parse_chatgpt_jwt_claims(id_token.expose_secret())
.map_err(|_| CodexOAuthAdminError::TokenRejected)?;
secret.id_token = Some(id_token);
let credential = if let Some(current) = current {
@@ -402,15 +402,26 @@ impl CodexCredentialRefreshService {
let TokenPair {
access_token,
refresh_token: rotated_refresh_token,
id_token: rotated_id_token,
expires_in,
} = tokens;
let access_token_rotated = access_token.is_some();
let CodexOAuthSecret {
access_token: current_access_token,
refresh_token: current_refresh_token,
id_token: current_id_token,
} = due.secret;
let refresh_token = rotated_refresh_token
.map(SecretString::from)
.or(due.secret.refresh_token);
.or(current_refresh_token);
let secret = CodexOAuthSecret {
access_token: SecretString::from(access_token),
access_token: access_token
.map(SecretString::from)
.unwrap_or(current_access_token),
refresh_token,
id_token: due.secret.id_token,
id_token: rotated_id_token
.map(SecretString::from)
.or(current_id_token),
};
record_oauth_recovery(
CodexOAuthRecoveryOperation::ScheduledRefresh,
@@ -422,7 +433,11 @@ impl CodexCredentialRefreshService {
.map(ExposeSecret::expose_secret),
);
let observed_at = SystemTime::now();
let access_token_expires_at = expires_in.and_then(|value| observed_at.checked_add(value));
let access_token_expires_at = if access_token_rotated {
expires_in.and_then(|value| observed_at.checked_add(value))
} else {
due.account.access_token_expires_at()
};
// 成功轮换清掉仅用于失败退避的 retry-not-before;正常预刷新窗口由
// worker 结合当前 runtime policy 动态判断,不写入账号时间字段。
let next_refresh_at = None;
@@ -21,8 +21,12 @@ pub const OFFICIAL_CODEX_REDIRECT_URI: &str = "http://localhost:1455/auth/callba
/// Token 刷新成功后得到的认证材料。
#[derive(Clone)]
pub struct TokenPair {
pub access_token: String,
/// 官方刷新响应省略时由持久化调用方保留当前 access token。
pub access_token: Option<String>,
/// 官方刷新响应省略时由持久化调用方保留当前 refresh token。
pub refresh_token: Option<String>,
/// 官方刷新响应省略时由持久化调用方保留当前 ID token。
pub id_token: Option<String>,
/// OAuth endpoint 可以省略或返回零 expiry;这不影响已经成功的 token
/// exchange,只意味着调用方不能据此安排下一次刷新。
pub expires_in: Option<Duration>,
@@ -32,11 +36,15 @@ impl fmt::Debug for TokenPair {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("TokenPair")
.field("access_token", &"[REDACTED]")
.field(
"access_token",
&self.access_token.as_ref().map(|_| "[REDACTED]"),
)
.field(
"refresh_token",
&self.refresh_token.as_ref().map(|_| "[REDACTED]"),
)
.field("id_token", &self.id_token.as_ref().map(|_| "[REDACTED]"))
.field("expires_in", &self.expires_in)
.finish()
}
@@ -172,8 +180,9 @@ pub fn official_openai_token_client() -> Result<OpenAiTokenClient, TokenClientBu
#[derive(Deserialize)]
struct RefreshTokenResponse {
access_token: String,
access_token: Option<String>,
refresh_token: Option<String>,
id_token: Option<String>,
expires_in: Option<u64>,
}
@@ -279,10 +288,19 @@ async fn read_bounded_response(
fn parse_token_pair(body: &[u8]) -> Result<TokenPair, ()> {
let tokens = serde_json::from_slice::<RefreshTokenResponse>(body).map_err(|_| ())?;
if tokens
.id_token
.as_deref()
.is_some_and(|token| super::types::parse_chatgpt_jwt_claims(token).is_err())
{
return Err(());
}
Ok(TokenPair {
access_token: tokens.access_token,
// OAuth 刷新响应可能省略未变更的 RT;缺失时由调用方保留当前值。
refresh_token: tokens.refresh_token,
// 官方刷新响应允许省略 ID token;缺失时由调用方保留当前值。
id_token: tokens.id_token,
expires_in: optional_expiry(tokens.expires_in),
})
}
@@ -42,7 +42,7 @@ pub struct CodexAccountProfile {
pub access_token_expires_at: Option<DateTime<Utc>>,
}
/// 从 OAuth ID token payload 尽力提取的展示资料。
/// 从 ChatGPT OAuth JWT payload 尽力提取的账号资料。
///
/// 这只是与官方客户端一致的 base64 JSON 读取,不校验 JWT 签名或 claims。
#[derive(Clone, Default)]
@@ -71,12 +71,13 @@ impl fmt::Debug for CodexOAuthMetadata {
}
}
/// 按官方 `token_data.rs` 的字段优先级读取 ID token payload。
/// 按官方 `token_data.rs::parse_chatgpt_jwt_claims` 的字段优先级读取 JWT payload。
///
/// 与官方一致,`id_token` 的 JWT 外形、payload base64 或 JSON 无法解析时返回错误;
/// 这不是签名或 claims 验证。各身份字段本身仍全部可缺失。
pub(crate) fn parse_id_token_metadata(jwt: &str) -> Result<CodexOAuthMetadata, ()> {
let claims = decode_jwt_payload::<IdTokenClaims>(jwt)?;
/// 官方对 ID token 和外部 ChatGPT access token 复用同一解析逻辑。JWT 外形、
/// payload base64 或 JSON 无法解析时返回错误;这不是签名或 claims 验证。
/// 各账号字段本身仍全部可缺失。
pub(crate) fn parse_chatgpt_jwt_claims(jwt: &str) -> Result<CodexOAuthMetadata, ()> {
let claims = decode_jwt_payload::<IdClaims>(jwt)?;
let email = claims
.email
.or_else(|| claims.profile.and_then(|profile| profile.email));
@@ -88,7 +89,7 @@ pub(crate) fn parse_id_token_metadata(jwt: &str) -> Result<CodexOAuthMetadata, (
};
Ok(CodexOAuthMetadata {
email,
chatgpt_plan_type: auth.chatgpt_plan_type,
chatgpt_plan_type: auth.chatgpt_plan_type.map(ChatgptPlanType::into_raw_value),
chatgpt_user_id: auth.chatgpt_user_id.or(auth.user_id),
chatgpt_account_id: auth.chatgpt_account_id,
})
@@ -106,7 +107,7 @@ pub(crate) fn parse_access_token_expiration(jwt: &str) -> Option<DateTime<Utc>>
}
#[derive(Deserialize)]
struct IdTokenClaims {
struct IdClaims {
#[serde(default)]
email: Option<String>,
#[serde(rename = "https://api.openai.com/profile", default)]
@@ -130,13 +131,79 @@ struct ProfileClaims {
#[derive(Deserialize)]
struct AuthClaims {
#[serde(default)]
chatgpt_plan_type: Option<String>,
chatgpt_plan_type: Option<ChatgptPlanType>,
#[serde(default)]
chatgpt_user_id: Option<String>,
#[serde(default)]
user_id: Option<String>,
#[serde(default)]
chatgpt_account_id: Option<String>,
#[serde(rename = "chatgpt_account_is_fedramp", default)]
_chatgpt_account_is_fedramp: bool,
}
/// 与官方 `codex_protocol::auth::PlanType` 同构的 claims 反序列化类型。
#[derive(Deserialize)]
#[serde(untagged)]
enum ChatgptPlanType {
Known(KnownChatgptPlan),
Unknown(String),
}
impl ChatgptPlanType {
fn into_raw_value(self) -> String {
match self {
Self::Known(plan) => plan.raw_value().to_owned(),
Self::Unknown(raw) => raw,
}
}
}
/// 与官方 `codex_protocol::auth::KnownPlan` 的 wire 名称和 aliases 保持一致。
#[derive(Clone, Copy, Deserialize)]
#[serde(rename_all = "lowercase")]
enum KnownChatgptPlan {
Free,
Go,
Plus,
Pro,
ProLite,
Team,
#[serde(rename = "self_serve_business_prolite")]
SelfServeBusinessProLite,
#[serde(rename = "self_serve_business_usage_based")]
SelfServeBusinessUsageBased,
Business,
Ent26,
#[serde(rename = "enterprise_cbp_automation")]
EnterpriseCbpAutomation,
#[serde(rename = "enterprise_cbp_usage_based")]
EnterpriseCbpUsageBased,
#[serde(alias = "hc")]
Enterprise,
#[serde(alias = "education")]
Edu,
}
impl KnownChatgptPlan {
const fn raw_value(self) -> &'static str {
match self {
Self::Free => "free",
Self::Go => "go",
Self::Plus => "plus",
Self::Pro => "pro",
Self::ProLite => "prolite",
Self::Team => "team",
Self::SelfServeBusinessProLite => "self_serve_business_prolite",
Self::SelfServeBusinessUsageBased => "self_serve_business_usage_based",
Self::Business => "business",
Self::Ent26 => "ent26",
Self::EnterpriseCbpAutomation => "enterprise_cbp_automation",
Self::EnterpriseCbpUsageBased => "enterprise_cbp_usage_based",
Self::Enterprise => "enterprise",
Self::Edu => "edu",
}
}
}
fn decode_jwt_payload<T: DeserializeOwned>(jwt: &str) -> Result<T, ()> {
+20 -7
View File
@@ -35,6 +35,7 @@ use gateway_core::task::{WorkerContribution, WorkerKind, WorkerRunnable};
use provider_openai::config::{CodexWireProfileConfig, OpenAiConfig};
use provider_openai::credential::ImportCodexOAuthCredential;
use provider_openai::transport::profile::APPCAST_POLL_INTERVAL;
use secrecy::SecretString;
use serde_json::{Map, Value, json};
use tempfile::TempDir;
@@ -360,11 +361,13 @@ async fn openai_reauthorization_pending_payload_contains_only_stable_account_ide
#[tokio::test]
async fn openai_admin_provider_projects_cached_quota_models_and_canonical_export() {
let store = Arc::new(MemoryAccountStore::default());
let mut oauth_secret = secret("admin-projection-access");
oauth_secret.id_token = Some(SecretString::from("header.id-token.signature"));
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: "acct_admin_projection".to_owned(),
name: "admin projection".to_owned(),
secret: secret("admin-projection-access"),
secret: oauth_secret,
verified_account: profile("chatgpt-admin-projection"),
next_refresh_at: Some(chrono::Utc::now() + chrono::Duration::minutes(30)),
enabled: true,
@@ -438,15 +441,25 @@ async fn openai_admin_provider_projects_cached_quota_models_and_canonical_export
.await
.expect("canonical export");
assert_eq!(exported.account_ids, vec![account_id]);
let document = exported.document.expose_to_provider().expose_to_provider();
assert_eq!(
exported
.document
.expose_to_provider()
.expose_to_provider()
.get("sourceFormat")
.and_then(Value::as_str),
document.get("sourceFormat").and_then(Value::as_str),
Some("cpr")
);
let exported_account = document
.get("accounts")
.and_then(Value::as_array)
.and_then(|accounts| accounts.first())
.expect("exported OAuth account");
assert_eq!(
exported_account.get("accessToken").and_then(Value::as_str),
Some("admin-projection-access")
);
assert_eq!(
exported_account.get("idToken").and_then(Value::as_str),
Some("header.id-token.signature")
);
assert!(exported_account.get("token").is_none());
}
#[tokio::test]
@@ -27,9 +27,9 @@ async fn direct_import_persists_opaque_tokens_without_profile_or_token_validatio
);
let prepared = service
.prepare_import_document(serde_json::json!({
"token": " ",
"refresh_token": "",
"id_token": "not.a.parseable.jwt"
"accessToken": " ",
"refreshToken": "",
"idToken": "not.a.parseable.jwt"
}))
.await
.expect("opaque direct import must be accepted");
@@ -79,8 +79,8 @@ async fn direct_import_projects_access_token_jwt_expiry_without_persisting_refre
);
let prepared = service
.prepare_import_document(serde_json::json!({
"token": access_token,
"refresh_token": "refresh-token"
"accessToken": access_token,
"refreshToken": "refresh-token"
}))
.await
.expect("direct JWT import must be accepted");
@@ -95,3 +95,147 @@ async fn direct_import_projects_access_token_jwt_expiry_without_persisting_refre
);
assert!(account.account.next_refresh_at().is_none());
}
fn test_jwt(payload: serde_json::Value) -> String {
format!(
"unverified-header.{}.unverified-signature",
URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("test JWT payload"))
)
}
#[tokio::test]
async fn oauth_import_uses_official_chatgpt_access_token_claims() {
let access_token = test_jwt(serde_json::json!({
"email": "top-level@example.com",
"https://api.openai.com/profile": {"email": "profile@example.com"},
"https://api.openai.com/auth": {
"chatgpt_plan_type": "pro",
"chatgpt_user_id": "chatgpt-user",
"user_id": "fallback-user",
"chatgpt_account_id": "chatgpt-account"
}
}));
let service = CodexCredentialAdminService::new(
Arc::new(UnusedRefresher),
Arc::new(TestLeaseCoordinator::default()),
runtime_policy(),
);
let prepared = service
.prepare_import_document(serde_json::json!({
"accessToken": access_token
}))
.await
.expect("official access token claims are accepted");
let account = &prepared.accounts().first().expect("one account").account;
assert_eq!(account.upstream_user_id(), Some("chatgpt-user"));
assert_eq!(account.upstream_account_id(), Some("chatgpt-account"));
assert_eq!(account.email(), Some("top-level@example.com"));
assert_eq!(account.plan_type(), Some("pro"));
}
#[tokio::test]
async fn oauth_import_uses_official_plan_alias_projection_and_user_id_fallback() {
let access_token = test_jwt(serde_json::json!({
"https://api.openai.com/auth": {
"chatgpt_plan_type": "hc",
"user_id": "fallback-user",
"chatgpt_account_is_fedramp": false
}
}));
let service = CodexCredentialAdminService::new(
Arc::new(UnusedRefresher),
Arc::new(TestLeaseCoordinator::default()),
runtime_policy(),
);
let prepared = service
.prepare_import_document(serde_json::json!({
"accessToken": access_token
}))
.await
.expect("official plan alias and user ID fallback are accepted");
let account = &prepared.accounts().first().expect("one account").account;
assert_eq!(account.upstream_user_id(), Some("fallback-user"));
assert_eq!(account.plan_type(), Some("enterprise"));
}
#[tokio::test]
async fn oauth_import_uses_id_token_then_access_token_for_missing_claims() {
let id_token = test_jwt(serde_json::json!({
"https://api.openai.com/profile": {"email": "id-token@example.com"},
"https://api.openai.com/auth": {"chatgpt_user_id": "id-token-user"}
}));
let access_token = test_jwt(serde_json::json!({
"email": "access-token@example.com",
"https://api.openai.com/auth": {
"chatgpt_plan_type": "plus",
"chatgpt_user_id": "access-token-user",
"chatgpt_account_id": "access-token-account"
}
}));
let service = CodexCredentialAdminService::new(
Arc::new(UnusedRefresher),
Arc::new(TestLeaseCoordinator::default()),
runtime_policy(),
);
let prepared = service
.prepare_import_document(serde_json::json!({
"accessToken": access_token,
"idToken": id_token
}))
.await
.expect("ID token and access token claims are accepted");
let account = &prepared.accounts().first().expect("one account").account;
assert_eq!(account.upstream_user_id(), Some("id-token-user"));
assert_eq!(account.upstream_account_id(), Some("access-token-account"));
assert_eq!(account.email(), Some("id-token@example.com"));
assert_eq!(account.plan_type(), Some("plus"));
}
#[tokio::test]
async fn oauth_import_does_not_use_top_level_identity_fields() {
let access_token = test_jwt(serde_json::json!({"exp": 2_000_000_000_i64}));
let service = CodexCredentialAdminService::new(
Arc::new(UnusedRefresher),
Arc::new(TestLeaseCoordinator::default()),
runtime_policy(),
);
let prepared = service
.prepare_import_document(serde_json::json!({
"accessToken": access_token,
"userId": "untrusted-user",
"accountId": "untrusted-account",
"email": "untrusted@example.com",
"planType": "pro"
}))
.await
.expect("token without identity claims remains importable");
let account = &prepared.accounts().first().expect("one account").account;
assert!(account.upstream_user_id().is_none());
assert!(account.upstream_account_id().is_none());
assert!(account.email().is_none());
assert!(account.plan_type().is_none());
}
#[tokio::test]
async fn oauth_import_rejects_legacy_bare_token_field() {
let service = CodexCredentialAdminService::new(
Arc::new(UnusedRefresher),
Arc::new(TestLeaseCoordinator::default()),
runtime_policy(),
);
let error = service
.prepare_import_document(serde_json::json!({
"token": "header.payload.signature"
}))
.await
.expect_err("ambiguous token field must not be accepted");
assert_eq!(
error,
provider_openai::credential::CodexCredentialAdminError::InvalidCredential
);
}
@@ -6,7 +6,7 @@ use std::time::{Duration, SystemTime};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use futures::future::BoxFuture;
use gateway_core::engine::credential::ProviderAccountId;
use gateway_core::engine::credential::{ProviderAccountId, ProviderAccountStore};
use gateway_core::provider_ports::{
ProviderCredentialState, ProviderCredentialStatePort, ProviderLeaseAcquisition,
ProviderLeasePort, ProviderLeaseRequest, ProviderRefreshPolicy, ProviderRuntimePolicyPort,
@@ -15,8 +15,10 @@ use gateway_core::provider_ports::{
use gateway_core::routing::ProviderKind;
use provider_openai::credential::token_client::{RefreshFailure, TokenPair, TokenRefresher};
use provider_openai::credential::{
CodexCredentialRefreshOutcome, CodexCredentialRefreshService, ImportCodexOAuthCredential,
CodexCredentialCodec, CodexCredentialRefreshOutcome, CodexCredentialRefreshService,
ImportCodexOAuthCredential,
};
use secrecy::{ExposeSecret as _, SecretString};
use crate::support::{MemoryAccountStore, profile, secret};
@@ -30,8 +32,9 @@ impl SingleUseRefresher {
Arc::new(Self {
calls: AtomicUsize::new(0),
response: Mutex::new(Some(TokenPair {
access_token: "refreshed-access-token".to_owned(),
access_token: Some("refreshed-access-token".to_owned()),
refresh_token: None,
id_token: None,
expires_in: Some(Duration::from_secs(60 * 60)),
})),
})
@@ -40,6 +43,30 @@ impl SingleUseRefresher {
fn calls(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
fn with_id_token(id_token: &str) -> Arc<Self> {
Arc::new(Self {
calls: AtomicUsize::new(0),
response: Mutex::new(Some(TokenPair {
access_token: Some("refreshed-access-token".to_owned()),
refresh_token: None,
id_token: Some(id_token.to_owned()),
expires_in: Some(Duration::from_secs(60 * 60)),
})),
})
}
fn without_rotated_tokens() -> Arc<Self> {
Arc::new(Self {
calls: AtomicUsize::new(0),
response: Mutex::new(Some(TokenPair {
access_token: None,
refresh_token: None,
id_token: None,
expires_in: Some(Duration::from_secs(60 * 60)),
})),
})
}
}
#[async_trait]
@@ -238,6 +265,77 @@ async fn scheduled_refresh_uses_the_current_margin_without_persisting_a_normal_s
);
}
#[tokio::test]
async fn scheduled_refresh_persists_a_returned_id_token() {
let store = Arc::new(MemoryAccountStore::default());
let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60));
let refresher = SingleUseRefresher::with_id_token("header.rotated-id.signature");
let service = refresh_service(&store, refresher, policy);
let account_id = "acct_rotated_id_token";
seed_refreshable_account(
&store,
account_id,
SystemTime::now()
.checked_add(Duration::from_secs(120))
.expect("test expiry"),
None,
)
.await;
service.refresh_due().await.expect("refresh cycle");
let account = store.account(account_id).expect("refreshed account");
let loaded = store
.load_credential(account.id(), account.revision())
.await
.expect("refreshed credential");
let runtime = CodexCredentialCodec::decode(&loaded.credential).expect("runtime credential");
assert_eq!(
runtime
.authentication
.oauth()
.and_then(|oauth| oauth.id_token.as_ref())
.map(SecretString::expose_secret),
Some("header.rotated-id.signature")
);
}
#[tokio::test]
async fn scheduled_refresh_preserves_the_stored_token_set_when_rotation_fields_are_omitted() {
let store = Arc::new(MemoryAccountStore::default());
let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60));
let refresher = SingleUseRefresher::without_rotated_tokens();
let service = refresh_service(&store, refresher, policy);
let account_id = "acct_omitted_token_rotation";
let expires_at = SystemTime::now()
.checked_add(Duration::from_secs(120))
.expect("test expiry");
seed_refreshable_account(&store, account_id, expires_at, None).await;
service.refresh_due().await.expect("refresh cycle");
let account = store.account(account_id).expect("refreshed account");
assert_eq!(account.access_token_expires_at(), Some(expires_at));
let loaded = store
.load_credential(account.id(), account.revision())
.await
.expect("refreshed credential");
let runtime = CodexCredentialCodec::decode(&loaded.credential).expect("runtime credential");
let oauth = runtime.authentication.oauth().expect("OAuth credential");
assert_eq!(
oauth.access_token.expose_secret(),
format!("access-{account_id}")
);
let expected_refresh_token = format!("rt-access-{account_id}");
assert_eq!(
oauth
.refresh_token
.as_ref()
.map(SecretString::expose_secret),
Some(expected_refresh_token.as_str())
);
}
#[tokio::test]
async fn scheduled_refresh_respects_retry_not_before_until_it_has_elapsed() {
let store = Arc::new(MemoryAccountStore::default());
@@ -59,6 +59,7 @@ async fn bounded_oauth_response_should_parse_lifetime_and_rotated_token() {
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "access-rotated",
"refresh_token": "refresh-rotated",
"id_token": "header.e30.signature",
"expires_in": 3600
})))
.expect(1)
@@ -70,11 +71,54 @@ async fn bounded_oauth_response_should_parse_lifetime_and_rotated_token() {
.await
.expect("bounded response");
assert_eq!(tokens.access_token, "access-rotated");
assert_eq!(tokens.access_token.as_deref(), Some("access-rotated"));
assert_eq!(tokens.refresh_token.as_deref(), Some("refresh-rotated"));
assert_eq!(tokens.id_token.as_deref(), Some("header.e30.signature"));
assert_eq!(tokens.expires_in, Some(Duration::from_secs(3600)));
}
#[tokio::test]
async fn refresh_response_should_reject_a_malformed_rotated_id_token() {
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",
"id_token": "not-a-jwt"
})))
.expect(1)
.mount(&server)
.await;
let failure = client(&server)
.refresh("refresh-initial")
.await
.expect_err("malformed ID token must not replace the stored token set");
assert_eq!(failure, RefreshFailure::Transport);
}
#[tokio::test]
async fn refresh_response_should_allow_all_rotated_tokens_to_be_omitted() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.expect(1)
.mount(&server)
.await;
let tokens = client(&server)
.refresh("refresh-initial")
.await
.expect("official refresh response fields are optional");
assert!(tokens.access_token.is_none());
assert!(tokens.refresh_token.is_none());
assert!(tokens.id_token.is_none());
assert!(tokens.expires_in.is_none());
}
#[tokio::test]
async fn refresh_response_keeps_the_upstream_rotated_token_verbatim() {
let server = MockServer::start().await;
+11 -6
View File
@@ -96,7 +96,7 @@ OpenAI 路径保留客户端 Responses wire 语义:请求 body 的未知字段
| `GET` | `/api/admin/accounts/detail` | `accountId` | 查询账号详情、额度和本地用量 |
| `GET` | `/api/admin/accounts/export` | `accountIds`、`confirm=export_sensitive_accounts` | 显式导出最多 200 个账号的敏感 Provider 文档 |
| `POST` | `/api/admin/accounts/import` | `{ provider, data }` | 导入或按上游身份更新账号 |
| `POST` | `/api/admin/accounts/refresh` | `{ accountId }` | 手工刷新 OAuth credential(AT/RT),不刷新额度 |
| `POST` | `/api/admin/accounts/refresh` | `{ accountId }` | 手工刷新 OAuth credential(`idToken` / `accessToken` / `refreshToken`),不刷新额度 |
| `POST` | `/api/admin/accounts/rotate` | OpenAI rotation 字段 | 手工替换 OpenAI OAuth token |
| `POST` | `/api/admin/accounts/enable` | `{ provider, accountId }` | 启用账号 |
| `POST` | `/api/admin/accounts/disable` | `{ provider, accountId }` | 禁用账号 |
@@ -150,6 +150,7 @@ OpenAI rotation 请求字段为:
{
"provider": "openai",
"accountId": "acct_...",
"idToken": "...",
"accessToken": "...",
"refreshToken": "..."
}
@@ -171,11 +172,15 @@ OAuth start 使用:
### OpenAI 身份、额度与状态
- OAuth 文件导入使用可用的 access token 调用 OpenAI 已认证账号接口,补全实际使用的账号 ID、用户 ID、
邮箱和套餐;仅有 refresh token 时,先换取 access token 后执行同一补全。导入不要求 JWT payload 含有
特定字段,不执行 JWKS 验签,也不将文件中的身份字段与上游资料比对。
- 首次 OAuth 保留回调 `state`、PKCE 与官方 token exchange;换得 access token 后调用同一已认证账号接口
补全账号资料。重新授权也保留这些回调保护,但只轮换目标账号的 token。回调地址只承载 `code`/`state`,
- OAuth 文件导入的 canonical 凭据字段为 `accessToken`、`refreshToken`、`idToken`,不接受含义模糊的
`token`。仅有 refresh token 时先换取 access token。
- 身份补全复用官方 `token_data.rs::parse_chatgpt_jwt_claims`:优先解析 `idToken`,缺失字段再由
`accessToken` 补齐;`email` 优先 JWT 顶层值、其次 `https://api.openai.com/profile.email`,用户 ID
优先 `chatgpt_user_id`、其次 `user_id`。该路径不调用 `whoami`,也不信任导入文档顶层的
`userId/accountId`。
- 首次 OAuth 保留回调 `state`、PKCE 与官方 token exchange,并持久化 `idToken`、`accessToken`、
`refreshToken`。刷新响应中的三个 token 字段均按官方语义独立轮换:返回新值时替换,省略时分别保留
现值。重新授权也保留这些回调保护,但只轮换目标账号的 token。回调地址只承载 `code`/`state`,
不以 host/path 形式作为拒绝条件。
- 账号文件导入和首次 OAuth 创建在 credential 提交后立即尝试一次额度观测。观测失败只记录告警,
不回滚已提交的账号;重新授权和手工或后台 RT 刷新只更新 token,不隐式等同于手工额度刷新,也不更新
+7 -5
View File
@@ -130,11 +130,13 @@ OAuth refresh 请求状态不明确时只按 OAuth 自身的有界退避推进
`refresh_token_expires_at` 不是公共 SQL 列或 Core 权威状态。xAI 可在 `provider_credentials_json` 内保存它作为 Provider 私有提示;真正失效以 refresh endpoint 返回的永久错误为准。
OpenAI OAuth 导入先取得可用 access token(仅有 refresh token 时先交换),再调用 OpenAI 已认证账号
接口补全实际使用的账号 ID、用户 ID、邮箱和套餐。首次 OAuth 在服务端 `state`、PKCE 与官方 token
exchange 成功后执行同一补全。两条路径不执行 JWKS 验签,不要求 JWT payload 具备指定字段,也不将导入
材料中的身份与上游资料做一致性比对。重新授权的目标绑定只保存目标账号 ID,不接受或冻结客户端提供的
credential revision;complete 阶段重新读取目标账号及其当前 revision,并以当前 revision 做最终 CAS。
OpenAI OAuth 导入先取得 access token(仅有 refresh token 时先交换),再复用官方
`token_data.rs::parse_chatgpt_jwt_claims` 的本地 JWT payload 解析逻辑:优先读取 `idToken`,字段缺失时
由 `accessToken` 补齐。解析器只读取 `email`、`https://api.openai.com/profile` 与
`https://api.openai.com/auth` 中的官方 claims,不调用 `whoami`,也不使用导入文档顶层的
`userId/accountId` 补身份。两个令牌都没有用户身份 claims 时,账号以未验证错误状态入库。首次 OAuth
在服务端 `state`、PKCE 与官方 token exchange 成功后解析并保存同一 token set。重新授权的目标绑定
只保存目标账号 ID,不接受或冻结客户端提供的 credential revision;complete 阶段重新读取目标账号及其当前 revision,并以当前 revision 做最终 CAS。
重新授权和手工或后台 RT refresh 只轮换目标账号的 token,保留既有账号资料与 OAuth principal。
credential 与 quota 是两组独立事实,不是两套对外状态。主动 quota refresh 会拒绝过期 Access Token;
@@ -73,7 +73,7 @@ const canReauthorize = computed(() => props.account.provider === 'openai')
@click.stop="(close(), emit('refresh', account.id))"
>
<RefreshCw class="size-3.5 text-(--cp-text-muted)" />
刷新 token
刷新令牌
</BaseButton>
<BaseButton
v-if="canReauthorize"