mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix(openai): align account import with official token claims
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -368,7 +368,15 @@ impl ProviderAdmin for OpenAiAdminProvider {
|
||||
if !account_matches_record(¤t.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(¤t.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, ()> {
|
||||
|
||||
@@ -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
@@ -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,不隐式等同于手工额度刷新,也不更新
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user