feat: support chatgpt web access token import

This commit is contained in:
Codex
2026-05-06 14:45:39 +08:00
parent 94a6f315ca
commit 7eaf3e7d03
11 changed files with 511 additions and 52 deletions

View File

@@ -1,4 +1,6 @@
use super::super::token_import::build_codex_access_token_import_auth_config;
use super::super::token_import::{
build_provider_access_token_import_auth_config, provider_type_supports_access_token_import,
};
use super::kiro_import::execute_admin_provider_oauth_kiro_batch_import;
use super::parse::{
apply_admin_provider_oauth_batch_import_hints, extract_admin_provider_oauth_batch_error_detail,
@@ -108,14 +110,16 @@ async fn resolve_admin_provider_oauth_batch_import_tokens(
Ok(payload) => payload,
Err(response) => {
let detail = extract_admin_provider_oauth_batch_error_detail(response).await;
if provider_type.eq_ignore_ascii_case("codex") {
if provider_type_supports_access_token_import(provider_type) {
if let Some(access_token) = access_token {
let (auth_config, expires_at) = build_codex_access_token_import_auth_config(
access_token,
Some(refresh_token),
entry.expires_at,
Some(detail.as_str()),
);
let (auth_config, expires_at) =
build_provider_access_token_import_auth_config(
provider_type,
access_token,
Some(refresh_token),
entry.expires_at,
Some(detail.as_str()),
);
return Ok(AdminProviderOAuthResolvedBatchImport {
access_token: access_token.to_string(),
auth_config,
@@ -147,11 +151,16 @@ async fn resolve_admin_provider_oauth_batch_import_tokens(
}
if let Some(access_token) = access_token {
if !provider_type.eq_ignore_ascii_case("codex") {
return Err("Access Token 导入仅支持 Codex Provider".to_string());
if !provider_type_supports_access_token_import(provider_type) {
return Err("Access Token 导入仅支持 Codex / ChatGPT Web Provider".to_string());
}
let (auth_config, expires_at) =
build_codex_access_token_import_auth_config(access_token, None, entry.expires_at, None);
let (auth_config, expires_at) = build_provider_access_token_import_auth_config(
provider_type,
access_token,
None,
entry.expires_at,
None,
);
return Ok(AdminProviderOAuthResolvedBatchImport {
access_token: access_token.to_string(),
auth_config,

View File

@@ -204,7 +204,10 @@ pub(super) fn apply_admin_provider_oauth_batch_import_hints(
entry: &AdminProviderOAuthBatchImportEntry,
auth_config: &mut serde_json::Map<String, serde_json::Value>,
) {
if !provider_type.eq_ignore_ascii_case("codex") {
if !matches!(
provider_type.trim().to_ascii_lowercase().as_str(),
"codex" | "chatgpt_web"
) {
return;
}
if let Some(account_id) = entry.account_id.as_ref() {

View File

@@ -15,7 +15,8 @@ use super::super::state::{
json_u64_value,
};
use super::token_import::{
build_codex_access_token_import_auth_config, normalize_single_import_tokens,
build_provider_access_token_import_auth_config, normalize_single_import_tokens,
provider_type_supports_access_token_import,
};
use crate::handlers::admin::provider::shared::paths::admin_provider_oauth_import_provider_id;
use crate::handlers::admin::request::{
@@ -43,9 +44,15 @@ fn import_payload_string(
snake_case: &str,
camel_case: &str,
) -> Option<String> {
payload
.get(snake_case)
.or_else(|| payload.get(camel_case))
import_payload_string_any(payload, &[snake_case, camel_case])
}
fn import_payload_string_any(
payload: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.find_map(|key| payload.get(*key))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
@@ -60,6 +67,59 @@ fn import_payload_u64(
json_u64_value(payload.get(snake_case).or_else(|| payload.get(camel_case)))
}
fn apply_single_import_hints(
provider_type: &str,
payload: &serde_json::Map<String, serde_json::Value>,
auth_config: &mut serde_json::Map<String, serde_json::Value>,
) {
if !provider_type_supports_access_token_import(provider_type) {
return;
}
for (target, keys) in [
("email", &["email", "oauth_email"][..]),
(
"account_id",
&[
"account_id",
"accountId",
"chatgpt_account_id",
"chatgptAccountId",
][..],
),
(
"account_user_id",
&[
"account_user_id",
"accountUserId",
"chatgpt_account_user_id",
"chatgptAccountUserId",
][..],
),
(
"plan_type",
&[
"plan_type",
"planType",
"chatgpt_plan_type",
"chatgptPlanType",
][..],
),
(
"user_id",
&["user_id", "userId", "chatgpt_user_id", "chatgptUserId"][..],
),
("account_name", &["account_name", "accountName"][..]),
] {
let Some(value) = import_payload_string_any(payload, keys) else {
continue;
};
auth_config
.entry(target.to_string())
.or_insert_with(|| json!(value));
}
}
async fn resolve_admin_provider_oauth_single_import_tokens(
state: &AdminAppState<'_>,
template: AdminProviderOAuthTemplate,
@@ -83,17 +143,19 @@ async fn resolve_admin_provider_oauth_single_import_tokens(
{
Ok(payload) => payload,
Err(response) => {
if provider_type.eq_ignore_ascii_case("codex") {
if provider_type_supports_access_token_import(provider_type) {
if let Some(access_token) = access_token
.map(str::trim)
.filter(|value| !value.is_empty())
{
let (auth_config, expires_at) = build_codex_access_token_import_auth_config(
access_token,
Some(refresh_token),
imported_expires_at,
Some("Refresh Token 验证失败,已回退为 Access Token 导入"),
);
let (auth_config, expires_at) =
build_provider_access_token_import_auth_config(
provider_type,
access_token,
Some(refresh_token),
imported_expires_at,
Some("Refresh Token 验证失败,已回退为 Access Token 导入"),
);
return Ok(AdminProviderOAuthSingleImportTokens {
access_token: access_token.to_string(),
auth_config,
@@ -135,15 +197,20 @@ async fn resolve_admin_provider_oauth_single_import_tokens(
"Refresh Token 或 Access Token 不能为空",
));
};
if !provider_type.eq_ignore_ascii_case("codex") {
if !provider_type_supports_access_token_import(provider_type) {
return Err(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"Access Token 导入仅支持 Codex Provider",
"Access Token 导入仅支持 Codex / ChatGPT Web Provider",
));
}
let (auth_config, expires_at) =
build_codex_access_token_import_auth_config(access_token, None, imported_expires_at, None);
let (auth_config, expires_at) = build_provider_access_token_import_auth_config(
provider_type,
access_token,
None,
imported_expires_at,
None,
);
Ok(AdminProviderOAuthSingleImportTokens {
access_token: access_token.to_string(),
auth_config,
@@ -266,9 +333,10 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
};
let AdminProviderOAuthSingleImportTokens {
access_token,
auth_config,
mut auth_config,
expires_at,
} = resolved_import;
apply_single_import_hints(&provider_type, &raw_payload, &mut auth_config);
let has_refresh_token = auth_config
.get("refresh_token")
.and_then(serde_json::Value::as_str)

View File

@@ -95,7 +95,15 @@ pub(super) fn decode_access_token_expires_at(access_token: &str) -> Option<u64>
json_u64_value(claims.get("exp"))
}
pub(super) fn build_codex_access_token_import_auth_config(
pub(super) fn provider_type_supports_access_token_import(provider_type: &str) -> bool {
matches!(
provider_type.trim().to_ascii_lowercase().as_str(),
"codex" | "chatgpt_web"
)
}
pub(super) fn build_provider_access_token_import_auth_config(
provider_type: &str,
access_token: &str,
refresh_token: Option<&str>,
imported_expires_at: Option<u64>,
@@ -106,7 +114,7 @@ pub(super) fn build_codex_access_token_import_auth_config(
"token_type": "Bearer",
});
let (mut auth_config, _, _, _) =
build_provider_oauth_auth_config_from_token_payload("codex", &token_payload);
build_provider_oauth_auth_config_from_token_payload(provider_type, &token_payload);
let refresh_token = refresh_token
.map(str::trim)
@@ -140,7 +148,7 @@ pub(super) fn build_codex_access_token_import_auth_config(
#[cfg(test)]
mod tests {
use super::{
build_codex_access_token_import_auth_config, decode_access_token_expires_at,
build_provider_access_token_import_auth_config, decode_access_token_expires_at,
looks_like_access_token, normalize_single_import_tokens,
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
@@ -178,7 +186,7 @@ mod tests {
}));
let (auth_config, expires_at) =
build_codex_access_token_import_auth_config(&token, None, None, None);
build_provider_access_token_import_auth_config("codex", &token, None, None, None);
assert_eq!(expires_at, Some(2_000_000_000));
assert_eq!(decode_access_token_expires_at(&token), Some(2_000_000_000));
@@ -197,8 +205,13 @@ mod tests {
"aud": ["https://api.openai.com/v1"]
}));
let (auth_config, expires_at) =
build_codex_access_token_import_auth_config(&token, None, Some(2_100_000_000), None);
let (auth_config, expires_at) = build_provider_access_token_import_auth_config(
"codex",
&token,
None,
Some(2_100_000_000),
None,
);
assert_eq!(expires_at, Some(2_100_000_000));
assert_eq!(
@@ -206,4 +219,35 @@ mod tests {
Some(&json!(2_100_000_000u64))
);
}
#[test]
fn builds_chatgpt_web_temporary_auth_config_from_access_token() {
let token = unsigned_jwt(json!({
"exp": 2_000_000_000u64,
"https://api.openai.com/profile": {
"email": "image@example.com"
},
"https://api.openai.com/auth": {
"chatgpt_account_id": "acct-image-123"
},
}));
let (auth_config, expires_at) =
build_provider_access_token_import_auth_config("chatgpt_web", &token, None, None, None);
assert_eq!(expires_at, Some(2_000_000_000));
assert_eq!(
auth_config.get("provider_type"),
Some(&json!("chatgpt_web"))
);
assert_eq!(auth_config.get("email"), Some(&json!("image@example.com")));
assert_eq!(
auth_config.get("account_id"),
Some(&json!("acct-image-123"))
);
assert_eq!(
auth_config.get("access_token_import_temporary"),
Some(&json!(true))
);
}
}

View File

@@ -26,11 +26,14 @@ fn normalize_provider_oauth_identity_value(value: Option<&serde_json::Value>) ->
.map(ToOwned::to_owned)
}
fn is_codex_provider_oauth_provider_type(value: Option<&serde_json::Value>) -> bool {
fn is_openai_provider_oauth_provider_type(value: Option<&serde_json::Value>) -> bool {
value
.and_then(serde_json::Value::as_str)
.map(str::trim)
.is_some_and(|provider_type| provider_type.eq_ignore_ascii_case("codex"))
.is_some_and(|provider_type| {
provider_type.eq_ignore_ascii_case("codex")
|| provider_type.eq_ignore_ascii_case("chatgpt_web")
})
}
fn match_codex_provider_oauth_identity(
@@ -39,8 +42,8 @@ fn match_codex_provider_oauth_identity(
) -> Option<bool> {
let new_provider_type = new_auth_config.get("provider_type");
let existing_provider_type = existing_auth_config.get("provider_type");
if !is_codex_provider_oauth_provider_type(new_provider_type)
&& !is_codex_provider_oauth_provider_type(existing_provider_type)
if !is_openai_provider_oauth_provider_type(new_provider_type)
&& !is_openai_provider_oauth_provider_type(existing_provider_type)
{
return None;
}
@@ -109,8 +112,8 @@ fn is_codex_cross_plan_group_non_duplicate(
) -> bool {
let new_provider_type = new_auth_config.get("provider_type");
let existing_provider_type = existing_auth_config.get("provider_type");
if !is_codex_provider_oauth_provider_type(new_provider_type)
&& !is_codex_provider_oauth_provider_type(existing_provider_type)
if !is_openai_provider_oauth_provider_type(new_provider_type)
&& !is_openai_provider_oauth_provider_type(existing_provider_type)
{
return false;
}

View File

@@ -1881,7 +1881,7 @@ fn admin_provider_oauth_refresh_helpers_use_specific_local_owners() {
for pattern in [
"fn normalize_codex_plan_group_for_provider_oauth(",
"fn normalize_provider_oauth_identity_value(",
"fn is_codex_provider_oauth_provider_type(",
"fn is_openai_provider_oauth_provider_type(",
"fn match_codex_provider_oauth_identity(",
"fn is_codex_cross_plan_group_non_duplicate(",
"pub(crate) async fn find_duplicate_provider_oauth_key(",

View File

@@ -1522,6 +1522,130 @@ async fn gateway_batch_imports_admin_provider_oauth_locally_with_trusted_admin_p
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_batch_imports_chatgpt_web_access_tokens_with_pool_hints() {
let token_hits = Arc::new(Mutex::new(0usize));
let token_hits_clone = Arc::clone(&token_hits);
let token_server = Router::new().route(
"/oauth/token",
any(move |_request: Request| {
let token_hits_inner = Arc::clone(&token_hits_clone);
async move {
*token_hits_inner.lock().expect("mutex should lock") += 1;
(
StatusCode::BAD_REQUEST,
Json(json!({"error": "unexpected refresh exchange"})),
)
}
}),
);
let mut provider = sample_provider("provider-chatgpt-web", "chatgpt_web", 10);
provider.provider_type = "chatgpt_web".to_string();
let endpoint = sample_endpoint(
"endpoint-chatgpt-web-image",
"provider-chatgpt-web",
"openai:image",
"https://chatgpt.com",
);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![provider],
vec![endpoint],
vec![],
));
let (token_url, token_handle) = start_server(token_server).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_repository_for_tests(
provider_catalog_repository.clone(),
)
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
)
.with_provider_oauth_token_url_for_tests(
"chatgpt_web",
format!("{token_url}/oauth/token"),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let credentials = json!([
{
"accessToken": "chatgpt-web-batch-access-token",
"expiresAt": 2_100_000_000u64,
"email": "pool-image@example.com",
"accountId": "acct-pool-image",
"accountUserId": "user-pool-image__acct-pool-image",
"planType": "plus",
"userId": "user-pool-image"
}
])
.to_string();
let response = reqwest::Client::new()
.post(format!(
"{gateway_url}/api/admin/provider-oauth/providers/provider-chatgpt-web/batch-import"
))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({
"credentials": credentials,
}))
.send()
.await
.expect("request should succeed");
let status = response.status();
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(status, StatusCode::OK, "payload={payload}");
assert_eq!(payload["total"], 1);
assert_eq!(payload["success"], 1);
assert_eq!(payload["failed"], 0);
assert_eq!(*token_hits.lock().expect("mutex should lock"), 0);
let reloaded = provider_catalog_repository
.list_keys_by_provider_ids(&["provider-chatgpt-web".to_string()])
.await
.expect("keys should load");
let persisted = reloaded.first().expect("persisted key should exist");
assert_eq!(persisted.expires_at_unix_secs, Some(2_100_000_000));
let decrypted_api_key = decrypt_python_fernet_ciphertext(
DEVELOPMENT_ENCRYPTION_KEY,
persisted
.encrypted_api_key
.as_deref()
.expect("api key should be present"),
)
.expect("api key should decrypt");
assert_eq!(decrypted_api_key, "chatgpt-web-batch-access-token");
let decrypted_auth_config = decrypt_python_fernet_ciphertext(
DEVELOPMENT_ENCRYPTION_KEY,
persisted
.encrypted_auth_config
.as_deref()
.expect("auth config should be stored"),
)
.expect("auth config should decrypt");
let auth_config: serde_json::Value =
serde_json::from_str(&decrypted_auth_config).expect("auth config json should parse");
assert_eq!(auth_config["provider_type"], "chatgpt_web");
assert_eq!(auth_config["access_token_import_temporary"], true);
assert_eq!(auth_config["email"], "pool-image@example.com");
assert_eq!(auth_config["account_id"], "acct-pool-image");
assert_eq!(
auth_config["account_user_id"],
"user-pool-image__acct-pool-image"
);
assert_eq!(auth_config["plan_type"], "plus");
assert_eq!(auth_config["user_id"], "user-pool-image");
gateway_handle.abort();
token_handle.abort();
}
#[tokio::test]
async fn gateway_starts_admin_provider_oauth_batch_import_task_locally_with_trusted_admin_principal(
) {
@@ -2468,6 +2592,117 @@ async fn gateway_imports_codex_access_token_without_refresh_token_as_temporary_a
token_handle.abort();
}
#[tokio::test]
async fn gateway_imports_chatgpt_web_access_token_without_refresh_token_as_temporary_account() {
let token_hits = Arc::new(Mutex::new(0usize));
let token_hits_clone = Arc::clone(&token_hits);
let token_server = Router::new().route(
"/oauth/token",
post(move || {
let token_hits_inner = Arc::clone(&token_hits_clone);
async move {
*token_hits_inner.lock().expect("mutex should lock") += 1;
(
StatusCode::BAD_REQUEST,
Json(json!({"error": "unexpected refresh exchange"})),
)
}
}),
);
let mut provider = sample_provider("provider-chatgpt-web", "chatgpt_web", 10);
provider.provider_type = "chatgpt_web".to_string();
let endpoint = sample_endpoint(
"endpoint-chatgpt-web-image",
"provider-chatgpt-web",
"openai:image",
"https://chatgpt.com",
);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![provider],
vec![endpoint],
vec![],
));
let (token_url, token_handle) = start_server(token_server).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_repository_for_tests(
provider_catalog_repository.clone(),
)
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
)
.with_provider_oauth_token_url_for_tests(
"chatgpt_web",
format!("{token_url}/oauth/token"),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let access_token =
sample_codex_access_token_with_profile_email("image@example.com", "acct-image-123");
let response = reqwest::Client::new()
.post(format!(
"{gateway_url}/api/admin/provider-oauth/providers/provider-chatgpt-web/import-refresh-token"
))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({
"access_token": access_token,
"name": "temporary-chatgpt-web-access-token",
}))
.send()
.await
.expect("request should succeed");
let status = response.status();
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(status, StatusCode::OK, "payload={payload}");
assert_eq!(payload["provider_type"], "chatgpt_web");
assert_eq!(payload["has_refresh_token"], false);
assert_eq!(payload["temporary"], true);
assert_eq!(payload["email"], "image@example.com");
assert_eq!(*token_hits.lock().expect("mutex should lock"), 0);
let reloaded = provider_catalog_repository
.list_keys_by_provider_ids(&["provider-chatgpt-web".to_string()])
.await
.expect("keys should load");
let persisted = reloaded.first().expect("persisted key should exist");
assert_eq!(persisted.expires_at_unix_secs, Some(2_000_000_000));
let decrypted_api_key = decrypt_python_fernet_ciphertext(
DEVELOPMENT_ENCRYPTION_KEY,
persisted
.encrypted_api_key
.as_deref()
.expect("api key should be present"),
)
.expect("api key should decrypt");
assert_eq!(decrypted_api_key, access_token);
let decrypted_auth_config = decrypt_python_fernet_ciphertext(
DEVELOPMENT_ENCRYPTION_KEY,
persisted
.encrypted_auth_config
.as_deref()
.expect("auth config should be stored"),
)
.expect("auth config should decrypt");
let auth_config: serde_json::Value =
serde_json::from_str(&decrypted_auth_config).expect("auth config json should parse");
assert_eq!(auth_config["provider_type"], "chatgpt_web");
assert_eq!(auth_config["access_token_import_temporary"], true);
assert_eq!(auth_config["email"], "image@example.com");
assert_eq!(auth_config["account_id"], "acct-image-123");
assert!(auth_config.get("refresh_token").is_none());
gateway_handle.abort();
token_handle.abort();
}
#[tokio::test]
async fn gateway_imports_codex_access_token_with_payload_expires_at_when_token_has_no_exp() {
let mut provider = sample_provider("provider-codex", "codex", 10);