mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
Merge pull request #387 from wendaochangsheng/codex/chatgpt-web-access-token-import
feat: 支持 ChatGPT Web Access Token 导入
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::kiro_import::execute_admin_provider_oauth_kiro_batch_import;
|
||||||
use super::parse::{
|
use super::parse::{
|
||||||
apply_admin_provider_oauth_batch_import_hints, extract_admin_provider_oauth_batch_error_detail,
|
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,
|
Ok(payload) => payload,
|
||||||
Err(response) => {
|
Err(response) => {
|
||||||
let detail = extract_admin_provider_oauth_batch_error_detail(response).await;
|
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 {
|
if let Some(access_token) = access_token {
|
||||||
let (auth_config, expires_at) = build_codex_access_token_import_auth_config(
|
let (auth_config, expires_at) =
|
||||||
access_token,
|
build_provider_access_token_import_auth_config(
|
||||||
Some(refresh_token),
|
provider_type,
|
||||||
entry.expires_at,
|
access_token,
|
||||||
Some(detail.as_str()),
|
Some(refresh_token),
|
||||||
);
|
entry.expires_at,
|
||||||
|
Some(detail.as_str()),
|
||||||
|
);
|
||||||
return Ok(AdminProviderOAuthResolvedBatchImport {
|
return Ok(AdminProviderOAuthResolvedBatchImport {
|
||||||
access_token: access_token.to_string(),
|
access_token: access_token.to_string(),
|
||||||
auth_config,
|
auth_config,
|
||||||
@@ -147,11 +151,16 @@ async fn resolve_admin_provider_oauth_batch_import_tokens(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(access_token) = access_token {
|
if let Some(access_token) = access_token {
|
||||||
if !provider_type.eq_ignore_ascii_case("codex") {
|
if !provider_type_supports_access_token_import(provider_type) {
|
||||||
return Err("Access Token 导入仅支持 Codex Provider".to_string());
|
return Err("Access Token 导入仅支持 Codex / ChatGPT Web Provider".to_string());
|
||||||
}
|
}
|
||||||
let (auth_config, expires_at) =
|
let (auth_config, expires_at) = build_provider_access_token_import_auth_config(
|
||||||
build_codex_access_token_import_auth_config(access_token, None, entry.expires_at, None);
|
provider_type,
|
||||||
|
access_token,
|
||||||
|
None,
|
||||||
|
entry.expires_at,
|
||||||
|
None,
|
||||||
|
);
|
||||||
return Ok(AdminProviderOAuthResolvedBatchImport {
|
return Ok(AdminProviderOAuthResolvedBatchImport {
|
||||||
access_token: access_token.to_string(),
|
access_token: access_token.to_string(),
|
||||||
auth_config,
|
auth_config,
|
||||||
|
|||||||
@@ -204,7 +204,10 @@ pub(super) fn apply_admin_provider_oauth_batch_import_hints(
|
|||||||
entry: &AdminProviderOAuthBatchImportEntry,
|
entry: &AdminProviderOAuthBatchImportEntry,
|
||||||
auth_config: &mut serde_json::Map<String, serde_json::Value>,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(account_id) = entry.account_id.as_ref() {
|
if let Some(account_id) = entry.account_id.as_ref() {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ use super::super::state::{
|
|||||||
json_u64_value,
|
json_u64_value,
|
||||||
};
|
};
|
||||||
use super::token_import::{
|
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::provider::shared::paths::admin_provider_oauth_import_provider_id;
|
||||||
use crate::handlers::admin::request::{
|
use crate::handlers::admin::request::{
|
||||||
@@ -43,9 +44,15 @@ fn import_payload_string(
|
|||||||
snake_case: &str,
|
snake_case: &str,
|
||||||
camel_case: &str,
|
camel_case: &str,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
payload
|
import_payload_string_any(payload, &[snake_case, camel_case])
|
||||||
.get(snake_case)
|
}
|
||||||
.or_else(|| payload.get(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)
|
.and_then(serde_json::Value::as_str)
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.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)))
|
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(
|
async fn resolve_admin_provider_oauth_single_import_tokens(
|
||||||
state: &AdminAppState<'_>,
|
state: &AdminAppState<'_>,
|
||||||
template: AdminProviderOAuthTemplate,
|
template: AdminProviderOAuthTemplate,
|
||||||
@@ -83,17 +143,19 @@ async fn resolve_admin_provider_oauth_single_import_tokens(
|
|||||||
{
|
{
|
||||||
Ok(payload) => payload,
|
Ok(payload) => payload,
|
||||||
Err(response) => {
|
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
|
if let Some(access_token) = access_token
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
{
|
{
|
||||||
let (auth_config, expires_at) = build_codex_access_token_import_auth_config(
|
let (auth_config, expires_at) =
|
||||||
access_token,
|
build_provider_access_token_import_auth_config(
|
||||||
Some(refresh_token),
|
provider_type,
|
||||||
imported_expires_at,
|
access_token,
|
||||||
Some("Refresh Token 验证失败,已回退为 Access Token 导入"),
|
Some(refresh_token),
|
||||||
);
|
imported_expires_at,
|
||||||
|
Some("Refresh Token 验证失败,已回退为 Access Token 导入"),
|
||||||
|
);
|
||||||
return Ok(AdminProviderOAuthSingleImportTokens {
|
return Ok(AdminProviderOAuthSingleImportTokens {
|
||||||
access_token: access_token.to_string(),
|
access_token: access_token.to_string(),
|
||||||
auth_config,
|
auth_config,
|
||||||
@@ -135,15 +197,20 @@ async fn resolve_admin_provider_oauth_single_import_tokens(
|
|||||||
"Refresh Token 或 Access Token 不能为空",
|
"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(
|
return Err(build_internal_control_error_response(
|
||||||
http::StatusCode::BAD_REQUEST,
|
http::StatusCode::BAD_REQUEST,
|
||||||
"Access Token 导入仅支持 Codex Provider",
|
"Access Token 导入仅支持 Codex / ChatGPT Web Provider",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let (auth_config, expires_at) =
|
let (auth_config, expires_at) = build_provider_access_token_import_auth_config(
|
||||||
build_codex_access_token_import_auth_config(access_token, None, imported_expires_at, None);
|
provider_type,
|
||||||
|
access_token,
|
||||||
|
None,
|
||||||
|
imported_expires_at,
|
||||||
|
None,
|
||||||
|
);
|
||||||
Ok(AdminProviderOAuthSingleImportTokens {
|
Ok(AdminProviderOAuthSingleImportTokens {
|
||||||
access_token: access_token.to_string(),
|
access_token: access_token.to_string(),
|
||||||
auth_config,
|
auth_config,
|
||||||
@@ -266,9 +333,10 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
|
|||||||
};
|
};
|
||||||
let AdminProviderOAuthSingleImportTokens {
|
let AdminProviderOAuthSingleImportTokens {
|
||||||
access_token,
|
access_token,
|
||||||
auth_config,
|
mut auth_config,
|
||||||
expires_at,
|
expires_at,
|
||||||
} = resolved_import;
|
} = resolved_import;
|
||||||
|
apply_single_import_hints(&provider_type, &raw_payload, &mut auth_config);
|
||||||
let has_refresh_token = auth_config
|
let has_refresh_token = auth_config
|
||||||
.get("refresh_token")
|
.get("refresh_token")
|
||||||
.and_then(serde_json::Value::as_str)
|
.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"))
|
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,
|
access_token: &str,
|
||||||
refresh_token: Option<&str>,
|
refresh_token: Option<&str>,
|
||||||
imported_expires_at: Option<u64>,
|
imported_expires_at: Option<u64>,
|
||||||
@@ -106,7 +114,7 @@ pub(super) fn build_codex_access_token_import_auth_config(
|
|||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
});
|
});
|
||||||
let (mut auth_config, _, _, _) =
|
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
|
let refresh_token = refresh_token
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
@@ -140,7 +148,7 @@ pub(super) fn build_codex_access_token_import_auth_config(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
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,
|
looks_like_access_token, normalize_single_import_tokens,
|
||||||
};
|
};
|
||||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||||
@@ -178,7 +186,7 @@ mod tests {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
let (auth_config, expires_at) =
|
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!(expires_at, Some(2_000_000_000));
|
||||||
assert_eq!(decode_access_token_expires_at(&token), 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"]
|
"aud": ["https://api.openai.com/v1"]
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let (auth_config, expires_at) =
|
let (auth_config, expires_at) = build_provider_access_token_import_auth_config(
|
||||||
build_codex_access_token_import_auth_config(&token, None, Some(2_100_000_000), None);
|
"codex",
|
||||||
|
&token,
|
||||||
|
None,
|
||||||
|
Some(2_100_000_000),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
assert_eq!(expires_at, Some(2_100_000_000));
|
assert_eq!(expires_at, Some(2_100_000_000));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -206,4 +219,35 @@ mod tests {
|
|||||||
Some(&json!(2_100_000_000u64))
|
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)
|
.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
|
value
|
||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.map(str::trim)
|
.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(
|
fn match_codex_provider_oauth_identity(
|
||||||
@@ -39,8 +42,8 @@ fn match_codex_provider_oauth_identity(
|
|||||||
) -> Option<bool> {
|
) -> Option<bool> {
|
||||||
let new_provider_type = new_auth_config.get("provider_type");
|
let new_provider_type = new_auth_config.get("provider_type");
|
||||||
let existing_provider_type = existing_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)
|
if !is_openai_provider_oauth_provider_type(new_provider_type)
|
||||||
&& !is_codex_provider_oauth_provider_type(existing_provider_type)
|
&& !is_openai_provider_oauth_provider_type(existing_provider_type)
|
||||||
{
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -109,8 +112,8 @@ fn is_codex_cross_plan_group_non_duplicate(
|
|||||||
) -> bool {
|
) -> bool {
|
||||||
let new_provider_type = new_auth_config.get("provider_type");
|
let new_provider_type = new_auth_config.get("provider_type");
|
||||||
let existing_provider_type = existing_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)
|
if !is_openai_provider_oauth_provider_type(new_provider_type)
|
||||||
&& !is_codex_provider_oauth_provider_type(existing_provider_type)
|
&& !is_openai_provider_oauth_provider_type(existing_provider_type)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1881,7 +1881,7 @@ fn admin_provider_oauth_refresh_helpers_use_specific_local_owners() {
|
|||||||
for pattern in [
|
for pattern in [
|
||||||
"fn normalize_codex_plan_group_for_provider_oauth(",
|
"fn normalize_codex_plan_group_for_provider_oauth(",
|
||||||
"fn normalize_provider_oauth_identity_value(",
|
"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 match_codex_provider_oauth_identity(",
|
||||||
"fn is_codex_cross_plan_group_non_duplicate(",
|
"fn is_codex_cross_plan_group_non_duplicate(",
|
||||||
"pub(crate) async fn find_duplicate_provider_oauth_key(",
|
"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();
|
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]
|
#[tokio::test]
|
||||||
async fn gateway_starts_admin_provider_oauth_batch_import_task_locally_with_trusted_admin_principal(
|
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();
|
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]
|
#[tokio::test]
|
||||||
async fn gateway_imports_codex_access_token_with_payload_expires_at_when_token_has_no_exp() {
|
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);
|
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
|
let auth = source
|
||||||
.get("https://api.openai.com/auth")
|
.get("https://api.openai.com/auth")
|
||||||
.and_then(Value::as_object);
|
.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;
|
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(
|
merge_missing_auth_config_fields(
|
||||||
auth_config,
|
auth_config,
|
||||||
&codex_fields,
|
&chatgpt_fields,
|
||||||
&[
|
&[
|
||||||
"email",
|
"email",
|
||||||
"account_id",
|
"account_id",
|
||||||
@@ -263,10 +272,10 @@ pub fn enrich_admin_provider_oauth_auth_config(
|
|||||||
"account_name",
|
"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(
|
merge_missing_auth_config_fields(
|
||||||
auth_config,
|
auth_config,
|
||||||
&codex_claim_fields,
|
&chatgpt_claim_fields,
|
||||||
&[
|
&[
|
||||||
"email",
|
"email",
|
||||||
"account_id",
|
"account_id",
|
||||||
@@ -318,7 +327,15 @@ pub fn build_kiro_device_key_name(email: Option<&str>, refresh_token: Option<&st
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[test]
|
||||||
fn parse_provider_oauth_callback_params_reads_openai_query_state() {
|
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("code").map(String::as_str), Some("code-value"));
|
||||||
assert_eq!(params.get("state").map(String::as_str), Some("nonce-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;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(access_token) = token_payload
|
if let Some(access_token) = token_payload
|
||||||
|
|||||||
@@ -101,7 +101,19 @@ export async function completeProviderLevelOAuth(
|
|||||||
|
|
||||||
export async function importProviderRefreshToken(
|
export async function importProviderRefreshToken(
|
||||||
providerId: string,
|
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> {
|
): Promise<ProviderOAuthCompleteResponseWithKey> {
|
||||||
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data)
|
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data)
|
||||||
return resp.data
|
return resp.data
|
||||||
|
|||||||
@@ -949,7 +949,18 @@ function isBatchImport(text: string): boolean {
|
|||||||
return lines.length > 1
|
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()
|
const trimmed = text.trim()
|
||||||
if (!trimmed) return null
|
if (!trimmed) return null
|
||||||
|
|
||||||
@@ -976,7 +987,14 @@ function parseImportText(text: string): { refresh_token?: string; access_token?:
|
|||||||
return {
|
return {
|
||||||
refresh_token: normalizedRefreshToken,
|
refresh_token: normalizedRefreshToken,
|
||||||
access_token: normalizedAccessToken,
|
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),
|
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
|
return null
|
||||||
@@ -992,6 +1010,23 @@ function parseImportText(text: string): { refresh_token?: string; access_token?:
|
|||||||
return { refresh_token: trimmed }
|
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 {
|
function isLikelyJwtToken(token: string): boolean {
|
||||||
const parts = token.trim().split('.')
|
const parts = token.trim().split('.')
|
||||||
if (parts.length !== 3 || parts.some(part => !part)) return false
|
if (parts.length !== 3 || parts.some(part => !part)) return false
|
||||||
|
|||||||
Reference in New Issue
Block a user