mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat: support chatgpt web access token import
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -115,7 +115,16 @@ fn first_json_non_empty_string(values: impl IntoIterator<Item = Option<Value>>)
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_codex_auth_fields_from_object(source: &Map<String, Value>) -> Map<String, Value> {
|
||||
fn provider_type_uses_openai_chatgpt_identity(provider_type: &str) -> bool {
|
||||
matches!(
|
||||
provider_type.trim().to_ascii_lowercase().as_str(),
|
||||
"codex" | "chatgpt_web"
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_openai_chatgpt_auth_fields_from_object(
|
||||
source: &Map<String, Value>,
|
||||
) -> Map<String, Value> {
|
||||
let auth = source
|
||||
.get("https://api.openai.com/auth")
|
||||
.and_then(Value::as_object);
|
||||
@@ -226,14 +235,14 @@ pub fn enrich_admin_provider_oauth_auth_config(
|
||||
],
|
||||
);
|
||||
|
||||
if !provider_type.eq_ignore_ascii_case("codex") {
|
||||
if !provider_type_uses_openai_chatgpt_identity(provider_type) {
|
||||
return;
|
||||
}
|
||||
|
||||
let codex_fields = extract_codex_auth_fields_from_object(token_payload_object);
|
||||
let chatgpt_fields = extract_openai_chatgpt_auth_fields_from_object(token_payload_object);
|
||||
merge_missing_auth_config_fields(
|
||||
auth_config,
|
||||
&codex_fields,
|
||||
&chatgpt_fields,
|
||||
&[
|
||||
"email",
|
||||
"account_id",
|
||||
@@ -263,10 +272,10 @@ pub fn enrich_admin_provider_oauth_auth_config(
|
||||
"account_name",
|
||||
],
|
||||
);
|
||||
let codex_claim_fields = extract_codex_auth_fields_from_object(&claims);
|
||||
let chatgpt_claim_fields = extract_openai_chatgpt_auth_fields_from_object(&claims);
|
||||
merge_missing_auth_config_fields(
|
||||
auth_config,
|
||||
&codex_claim_fields,
|
||||
&chatgpt_claim_fields,
|
||||
&[
|
||||
"email",
|
||||
"account_id",
|
||||
@@ -318,7 +327,15 @@ pub fn build_kiro_device_key_name(email: Option<&str>, refresh_token: Option<&st
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_provider_oauth_callback_params;
|
||||
use super::{enrich_admin_provider_oauth_auth_config, parse_provider_oauth_callback_params};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use serde_json::json;
|
||||
|
||||
fn sample_unsigned_jwt(payload: serde_json::Value) -> String {
|
||||
let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#);
|
||||
let payload = URL_SAFE_NO_PAD.encode(payload.to_string());
|
||||
format!("{header}.{payload}.sig")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_provider_oauth_callback_params_reads_openai_query_state() {
|
||||
@@ -359,4 +376,34 @@ mod tests {
|
||||
assert_eq!(params.get("code").map(String::as_str), Some("code-value"));
|
||||
assert_eq!(params.get("state").map(String::as_str), Some("nonce-value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatgpt_web_enrichment_extracts_identity_from_openai_claims() {
|
||||
let access_token = sample_unsigned_jwt(json!({
|
||||
"https://api.openai.com/profile": {
|
||||
"email": "image@example.com",
|
||||
},
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "acc-image",
|
||||
"chatgpt_account_user_id": "user-image__acc-image",
|
||||
"chatgpt_plan_type": "plus",
|
||||
"chatgpt_user_id": "user-image",
|
||||
},
|
||||
}));
|
||||
let token_payload = json!({
|
||||
"access_token": access_token,
|
||||
});
|
||||
let mut auth_config = serde_json::Map::new();
|
||||
|
||||
enrich_admin_provider_oauth_auth_config("chatgpt_web", &mut auth_config, &token_payload);
|
||||
|
||||
assert_eq!(auth_config.get("email"), Some(&json!("image@example.com")));
|
||||
assert_eq!(auth_config.get("account_id"), Some(&json!("acc-image")));
|
||||
assert_eq!(
|
||||
auth_config.get("account_user_id"),
|
||||
Some(&json!("user-image__acc-image"))
|
||||
);
|
||||
assert_eq!(auth_config.get("plan_type"), Some(&json!("plus")));
|
||||
assert_eq!(auth_config.get("user_id"), Some(&json!("user-image")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,7 +469,10 @@ fn enrich_generic_identity(
|
||||
}
|
||||
}
|
||||
}
|
||||
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(access_token) = token_payload
|
||||
|
||||
@@ -101,7 +101,19 @@ export async function completeProviderLevelOAuth(
|
||||
|
||||
export async function importProviderRefreshToken(
|
||||
providerId: string,
|
||||
data: { refresh_token?: string; access_token?: string; name?: string; proxy_node_id?: string }
|
||||
data: {
|
||||
refresh_token?: string
|
||||
access_token?: string
|
||||
expires_at?: number
|
||||
name?: string
|
||||
proxy_node_id?: string
|
||||
email?: string
|
||||
account_id?: string
|
||||
account_user_id?: string
|
||||
plan_type?: string
|
||||
user_id?: string
|
||||
account_name?: string
|
||||
}
|
||||
): Promise<ProviderOAuthCompleteResponseWithKey> {
|
||||
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data)
|
||||
return resp.data
|
||||
|
||||
@@ -949,7 +949,18 @@ function isBatchImport(text: string): boolean {
|
||||
return lines.length > 1
|
||||
}
|
||||
|
||||
function parseImportText(text: string): { refresh_token?: string; access_token?: string; name?: string } | null {
|
||||
function parseImportText(text: string): {
|
||||
refresh_token?: string
|
||||
access_token?: string
|
||||
expires_at?: number
|
||||
name?: string
|
||||
email?: string
|
||||
account_id?: string
|
||||
account_user_id?: string
|
||||
plan_type?: string
|
||||
user_id?: string
|
||||
account_name?: string
|
||||
} | null {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
@@ -976,7 +987,14 @@ function parseImportText(text: string): { refresh_token?: string; access_token?:
|
||||
return {
|
||||
refresh_token: normalizedRefreshToken,
|
||||
access_token: normalizedAccessToken,
|
||||
expires_at: normalizeNumberField(obj.expires_at) ?? normalizeNumberField(obj.expiresAt),
|
||||
name: (typeof obj.name === 'string' ? obj.name : undefined) || (typeof obj.oauth_email === 'string' ? obj.oauth_email : undefined),
|
||||
email: normalizeStringField(obj.email) ?? normalizeStringField(obj.oauth_email),
|
||||
account_id: normalizeStringField(obj.account_id) ?? normalizeStringField(obj.accountId) ?? normalizeStringField(obj.chatgpt_account_id) ?? normalizeStringField(obj.chatgptAccountId),
|
||||
account_user_id: normalizeStringField(obj.account_user_id) ?? normalizeStringField(obj.accountUserId) ?? normalizeStringField(obj.chatgpt_account_user_id) ?? normalizeStringField(obj.chatgptAccountUserId),
|
||||
plan_type: normalizeStringField(obj.plan_type) ?? normalizeStringField(obj.planType) ?? normalizeStringField(obj.chatgpt_plan_type) ?? normalizeStringField(obj.chatgptPlanType),
|
||||
user_id: normalizeStringField(obj.user_id) ?? normalizeStringField(obj.userId) ?? normalizeStringField(obj.chatgpt_user_id) ?? normalizeStringField(obj.chatgptUserId),
|
||||
account_name: normalizeStringField(obj.account_name) ?? normalizeStringField(obj.accountName),
|
||||
}
|
||||
}
|
||||
return null
|
||||
@@ -992,6 +1010,23 @@ function parseImportText(text: string): { refresh_token?: string; access_token?:
|
||||
return { refresh_token: trimmed }
|
||||
}
|
||||
|
||||
function normalizeStringField(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
||||
}
|
||||
|
||||
function normalizeNumberField(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||
return Math.floor(value)
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value.trim())
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return Math.floor(parsed)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isLikelyJwtToken(token: string): boolean {
|
||||
const parts = token.trim().split('.')
|
||||
if (parts.length !== 3 || parts.some(part => !part)) return false
|
||||
|
||||
Reference in New Issue
Block a user