feat(grok): add admin oauth and quota support

This commit is contained in:
mayrain
2026-05-16 21:15:38 +08:00
parent cbfe1d378f
commit 936e1ae37b
61 changed files with 4748 additions and 287 deletions
@@ -1,3 +1,4 @@
use super::super::helpers::admin_provider_oauth_key_name_from_auth_config;
use super::super::token_import::{
build_provider_access_token_import_auth_config, provider_type_supports_access_token_import,
};
@@ -24,13 +25,11 @@ use crate::handlers::admin::provider::oauth::runtime::{
use crate::handlers::admin::provider::oauth::state::{
admin_provider_oauth_template, exchange_admin_provider_oauth_refresh_token,
};
use crate::handlers::admin::provider::shared::support::ADMIN_PROVIDER_OAUTH_DATA_UNAVAILABLE_DETAIL;
use crate::handlers::admin::request::{AdminAppState, AdminProviderOAuthTemplate};
use crate::GatewayError;
use aether_admin::provider::oauth::parse_admin_provider_oauth_kiro_batch_import_entries;
use aether_contracts::ProxySnapshot;
use serde_json::{json, Map, Value};
use std::time::{SystemTime, UNIX_EPOCH};
struct AdminProviderOAuthResolvedBatchImport {
access_token: String,
@@ -45,7 +44,7 @@ pub(super) fn estimate_admin_provider_oauth_batch_import_total(
if provider_type.eq_ignore_ascii_case("kiro") {
parse_admin_provider_oauth_kiro_batch_import_entries(raw_credentials).len()
} else {
parse_admin_provider_oauth_batch_import_entries(raw_credentials).len()
parse_admin_provider_oauth_batch_import_entries(provider_type, raw_credentials).len()
}
}
@@ -67,7 +66,8 @@ pub(super) async fn execute_admin_provider_oauth_batch_import_for_provider_type(
)
.await
} else {
let entries = parse_admin_provider_oauth_batch_import_entries(raw_credentials);
let entries =
parse_admin_provider_oauth_batch_import_entries(provider_type, raw_credentials);
execute_admin_provider_oauth_batch_import(
state,
provider_id,
@@ -82,7 +82,7 @@ pub(super) async fn execute_admin_provider_oauth_batch_import_for_provider_type(
async fn resolve_admin_provider_oauth_batch_import_tokens(
state: &AdminAppState<'_>,
template: AdminProviderOAuthTemplate,
template: Option<AdminProviderOAuthTemplate>,
provider_type: &str,
entry: &AdminProviderOAuthBatchImportEntry,
request_proxy: Option<ProxySnapshot>,
@@ -99,6 +99,29 @@ async fn resolve_admin_provider_oauth_batch_import_tokens(
.filter(|value| !value.is_empty());
if let Some(refresh_token) = refresh_token {
let Some(template) = template else {
if provider_type_supports_access_token_import(provider_type) {
if let Some(access_token) = access_token {
let (auth_config, expires_at) = build_provider_access_token_import_auth_config(
provider_type,
access_token,
Some(refresh_token),
entry.expires_at,
Some("Provider 不支持 Refresh Token 交换,已回退为 Session Token 导入"),
);
return Ok(AdminProviderOAuthResolvedBatchImport {
access_token: access_token.to_string(),
auth_config,
expires_at,
});
}
}
return Err(
"该 Provider 不支持 Refresh Token 导入,请提供 sso_token 或 access_token"
.to_string(),
);
};
let token_payload = match exchange_admin_provider_oauth_refresh_token(
state,
template,
@@ -152,7 +175,7 @@ async fn resolve_admin_provider_oauth_batch_import_tokens(
if let Some(access_token) = access_token {
if !provider_type_supports_access_token_import(provider_type) {
return Err("Access Token 导入仅支持 Codex / ChatGPT Web Provider".to_string());
return Err("Access Token 导入仅支持 Codex / ChatGPT Web / Grok Provider".to_string());
}
let (auth_config, expires_at) = build_provider_access_token_import_auth_config(
provider_type,
@@ -204,25 +227,7 @@ pub(super) async fn execute_admin_provider_oauth_batch_import(
});
};
let Some(template) = admin_provider_oauth_template(provider_type) else {
return Ok(AdminProviderOAuthBatchImportOutcome {
total: entries.len(),
success: 0,
failed: entries.len(),
results: entries
.iter()
.enumerate()
.map(|(index, _)| {
json!({
"index": index,
"status": "error",
"error": ADMIN_PROVIDER_OAUTH_DATA_UNAVAILABLE_DETAIL,
"replaced": false,
})
})
.collect(),
});
};
let template = admin_provider_oauth_template(provider_type);
let endpoint_resolution =
resolve_provider_oauth_runtime_endpoints(state, &provider, provider_type).await?;
@@ -340,24 +345,11 @@ pub(super) async fn execute_admin_provider_oauth_batch_import(
}
}
} else {
let key_name = auth_config
.get("email")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|email| format!("{provider_type}_{email}"))
.unwrap_or_else(|| {
format!(
"{}_{}_{}",
provider_type,
SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs())
.unwrap_or(0),
index
)
});
let key_name = admin_provider_oauth_key_name_from_auth_config(
provider_type,
&auth_config,
Some(index),
);
match create_provider_oauth_catalog_key(
state,
provider_id,
@@ -8,7 +8,7 @@ use super::parse::{
};
use crate::handlers::admin::provider::oauth::errors::build_internal_control_error_response;
use crate::handlers::admin::provider::oauth::state::{
admin_provider_oauth_template, build_admin_provider_oauth_backend_unavailable_response,
build_admin_provider_oauth_backend_unavailable_response,
is_fixed_provider_type_for_provider_oauth,
};
use crate::handlers::admin::provider::shared::paths::admin_provider_oauth_batch_import_provider_id;
@@ -60,10 +60,6 @@ pub(in super::super) async fn handle_admin_provider_oauth_batch_import(
"该 Provider 不是固定类型,无法使用 provider-oauth",
));
}
if provider_type != "kiro" && admin_provider_oauth_template(&provider_type).is_none() {
return Ok(build_admin_provider_oauth_backend_unavailable_response());
}
let total = estimate_admin_provider_oauth_batch_import_total(
&provider_type,
payload.credentials.as_str(),
@@ -1,4 +1,4 @@
use super::super::token_import::{import_tokens_from_raw_token, normalize_single_import_tokens};
use super::super::token_import::{import_tokens_from_raw_token, normalize_provider_import_tokens};
use crate::handlers::admin::provider::oauth::errors::build_internal_control_error_response;
use crate::handlers::admin::provider::oauth::state::{current_unix_secs, json_u64_value};
use axum::{
@@ -25,8 +25,15 @@ pub(super) struct AdminProviderOAuthBatchImportEntry {
pub account_id: Option<String>,
pub account_user_id: Option<String>,
pub plan_type: Option<String>,
pub pool_tier: Option<String>,
pub user_id: Option<String>,
pub email: Option<String>,
pub account_name: Option<String>,
pub sso_rw_token: Option<String>,
pub cf_cookies: Option<String>,
pub cf_clearance: Option<String>,
pub user_agent: Option<String>,
pub browser_profile: Option<String>,
}
#[derive(Debug, Clone)]
@@ -67,16 +74,72 @@ fn coerce_admin_provider_oauth_import_str(value: Option<&serde_json::Value>) ->
.map(ToOwned::to_owned)
}
fn grok_cookie_value(raw: &str, name: &str) -> Option<String> {
raw.trim()
.strip_prefix("Cookie:")
.unwrap_or_else(|| raw.trim())
.split(';')
.filter_map(|segment| segment.trim().split_once('='))
.find_map(|(cookie_name, cookie_value)| {
cookie_name
.trim()
.eq_ignore_ascii_case(name)
.then(|| cookie_value.trim())
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
}
fn grok_cookie_profile(raw: &str) -> Option<String> {
let raw = raw
.trim()
.strip_prefix("Cookie:")
.unwrap_or_else(|| raw.trim());
let parts = raw
.split(';')
.filter_map(|segment| {
let (cookie_name, cookie_value) = segment.trim().split_once('=')?;
let cookie_name = cookie_name.trim();
let cookie_value = cookie_value.trim();
if cookie_name.is_empty()
|| cookie_value.is_empty()
|| cookie_name.eq_ignore_ascii_case("sso")
|| cookie_name.eq_ignore_ascii_case("sso-rw")
{
return None;
}
Some(format!("{cookie_name}={cookie_value}"))
})
.collect::<Vec<_>>();
(!parts.is_empty()).then(|| parts.join("; "))
}
fn grok_cookie_session_token(provider_type: &str, raw: &str) -> Option<String> {
provider_type
.trim()
.eq_ignore_ascii_case("grok")
.then(|| grok_cookie_value(raw, "sso"))
.flatten()
}
fn extract_admin_provider_oauth_batch_import_entry(
provider_type: &str,
item: &serde_json::Value,
) -> Option<AdminProviderOAuthBatchImportEntry> {
match item {
serde_json::Value::String(value) => {
let refresh_token = value.trim();
if refresh_token.is_empty() {
let raw_token = value.trim();
if raw_token.is_empty() {
None
} else {
let (refresh_token, access_token) = import_tokens_from_raw_token(refresh_token);
let sso_from_cookie = grok_cookie_session_token(provider_type, raw_token);
let token_input = sso_from_cookie.as_deref().unwrap_or(raw_token);
let (refresh_token, access_token) = import_tokens_from_raw_token(token_input);
let (refresh_token, access_token) = normalize_provider_import_tokens(
provider_type,
refresh_token.as_deref(),
access_token.as_deref(),
);
Some(AdminProviderOAuthBatchImportEntry {
refresh_token,
access_token,
@@ -84,8 +147,15 @@ fn extract_admin_provider_oauth_batch_import_entry(
account_id: None,
account_user_id: None,
plan_type: None,
user_id: None,
pool_tier: None,
user_id: grok_cookie_value(raw_token, "x-userid"),
email: None,
account_name: None,
sso_rw_token: grok_cookie_value(raw_token, "sso-rw"),
cf_cookies: grok_cookie_profile(raw_token),
cf_clearance: grok_cookie_value(raw_token, "cf_clearance"),
user_agent: None,
browser_profile: None,
})
}
}
@@ -100,8 +170,34 @@ fn extract_admin_provider_oauth_batch_import_entry(
.get("access_token")
.or_else(|| object.get("accessToken")),
);
let (refresh_token, access_token) =
normalize_single_import_tokens(refresh_token.as_deref(), access_token.as_deref());
let grok_token_alias = if provider_type.trim().eq_ignore_ascii_case("grok") {
object.get("token")
} else {
None
};
let grok_cookie = if provider_type.trim().eq_ignore_ascii_case("grok") {
coerce_admin_provider_oauth_import_str(
object.get("cookie").or_else(|| object.get("cookieHeader")),
)
} else {
None
};
let session_token = coerce_admin_provider_oauth_import_str(
object
.get("sso_token")
.or_else(|| object.get("ssoToken"))
.or(grok_token_alias),
)
.or_else(|| {
grok_cookie
.as_deref()
.and_then(|cookie| grok_cookie_value(cookie, "sso"))
});
let (refresh_token, access_token) = normalize_provider_import_tokens(
provider_type,
refresh_token.as_deref(),
access_token.as_deref().or(session_token.as_deref()),
);
if refresh_token.is_none() && access_token.is_none() {
return None;
}
@@ -129,14 +225,65 @@ fn extract_admin_provider_oauth_batch_import_entry(
.or_else(|| object.get("chatgptPlanType")),
)
.map(|value| value.to_ascii_lowercase());
let pool_tier = coerce_admin_provider_oauth_import_str(
object
.get("pool_tier")
.or_else(|| object.get("poolTier"))
.or_else(|| object.get("tier")),
)
.map(|value| value.to_ascii_lowercase());
let user_id = coerce_admin_provider_oauth_import_str(
object
.get("user_id")
.or_else(|| object.get("userId"))
.or_else(|| object.get("chatgpt_user_id"))
.or_else(|| object.get("chatgptUserId")),
);
)
.or_else(|| {
grok_cookie
.as_deref()
.and_then(|cookie| grok_cookie_value(cookie, "x-userid"))
});
let email = coerce_admin_provider_oauth_import_str(object.get("email"));
let account_name = coerce_admin_provider_oauth_import_str(
object
.get("account_name")
.or_else(|| object.get("accountName")),
);
let sso_rw_token = coerce_admin_provider_oauth_import_str(
object
.get("sso_rw_token")
.or_else(|| object.get("ssoRwToken")),
)
.or_else(|| {
grok_cookie
.as_deref()
.and_then(|cookie| grok_cookie_value(cookie, "sso-rw"))
});
let cf_clearance = coerce_admin_provider_oauth_import_str(
object
.get("cf_clearance")
.or_else(|| object.get("cfClearance")),
)
.or_else(|| {
grok_cookie
.as_deref()
.and_then(|cookie| grok_cookie_value(cookie, "cf_clearance"))
});
let cf_cookies = coerce_admin_provider_oauth_import_str(
object.get("cf_cookies").or_else(|| object.get("cfCookies")),
)
.or_else(|| grok_cookie.as_deref().and_then(grok_cookie_profile));
let user_agent = coerce_admin_provider_oauth_import_str(
object.get("user_agent").or_else(|| object.get("userAgent")),
);
let browser_profile = coerce_admin_provider_oauth_import_str(
object
.get("browser_profile")
.or_else(|| object.get("browserProfile"))
.or_else(|| object.get("browser"))
.or_else(|| object.get("impersonate")),
);
Some(AdminProviderOAuthBatchImportEntry {
refresh_token,
access_token,
@@ -144,8 +291,15 @@ fn extract_admin_provider_oauth_batch_import_entry(
account_id,
account_user_id,
plan_type,
pool_tier,
user_id,
email,
account_name,
sso_rw_token,
cf_cookies,
cf_clearance,
user_agent,
browser_profile,
})
}
_ => None,
@@ -153,6 +307,7 @@ fn extract_admin_provider_oauth_batch_import_entry(
}
pub(super) fn parse_admin_provider_oauth_batch_import_entries(
provider_type: &str,
raw_credentials: &str,
) -> Vec<AdminProviderOAuthBatchImportEntry> {
let raw = raw_credentials.trim();
@@ -165,7 +320,9 @@ pub(super) fn parse_admin_provider_oauth_batch_import_entries(
{
return items
.iter()
.filter_map(extract_admin_provider_oauth_batch_import_entry)
.filter_map(|item| {
extract_admin_provider_oauth_batch_import_entry(provider_type, item)
})
.collect();
}
}
@@ -174,7 +331,7 @@ pub(super) fn parse_admin_provider_oauth_batch_import_entries(
if let Ok(value @ serde_json::Value::Object(_)) =
serde_json::from_str::<serde_json::Value>(raw)
{
return extract_admin_provider_oauth_batch_import_entry(&value)
return extract_admin_provider_oauth_batch_import_entry(provider_type, &value)
.into_iter()
.collect();
}
@@ -183,18 +340,19 @@ pub(super) fn parse_admin_provider_oauth_batch_import_entries(
raw.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(|token| {
let (refresh_token, access_token) = import_tokens_from_raw_token(token);
AdminProviderOAuthBatchImportEntry {
refresh_token,
access_token,
expires_at: None,
account_id: None,
account_user_id: None,
plan_type: None,
user_id: None,
email: None,
.filter_map(|line| {
if line.starts_with('{') {
return serde_json::from_str::<serde_json::Value>(line)
.ok()
.and_then(|value| {
extract_admin_provider_oauth_batch_import_entry(provider_type, &value)
});
}
extract_admin_provider_oauth_batch_import_entry(
provider_type,
&serde_json::Value::String(line.to_string()),
)
})
.collect()
}
@@ -204,10 +362,8 @@ pub(super) fn apply_admin_provider_oauth_batch_import_hints(
entry: &AdminProviderOAuthBatchImportEntry,
auth_config: &mut serde_json::Map<String, serde_json::Value>,
) {
if !matches!(
provider_type.trim().to_ascii_lowercase().as_str(),
"codex" | "chatgpt_web"
) {
let provider_type = provider_type.trim().to_ascii_lowercase();
if !matches!(provider_type.as_str(), "codex" | "chatgpt_web" | "grok") {
return;
}
if let Some(account_id) = entry.account_id.as_ref() {
@@ -225,6 +381,11 @@ pub(super) fn apply_admin_provider_oauth_batch_import_hints(
.entry("plan_type".to_string())
.or_insert_with(|| json!(plan_type));
}
if let Some(pool_tier) = entry.pool_tier.as_ref() {
auth_config
.entry("pool_tier".to_string())
.or_insert_with(|| json!(pool_tier));
}
if let Some(user_id) = entry.user_id.as_ref() {
auth_config
.entry("user_id".to_string())
@@ -235,6 +396,36 @@ pub(super) fn apply_admin_provider_oauth_batch_import_hints(
.entry("email".to_string())
.or_insert_with(|| json!(email));
}
if let Some(account_name) = entry.account_name.as_ref() {
auth_config
.entry("account_name".to_string())
.or_insert_with(|| json!(account_name));
}
if let Some(sso_rw_token) = entry.sso_rw_token.as_ref() {
auth_config
.entry("sso_rw_token".to_string())
.or_insert_with(|| json!(sso_rw_token));
}
if let Some(cf_cookies) = entry.cf_cookies.as_ref() {
auth_config
.entry("cf_cookies".to_string())
.or_insert_with(|| json!(cf_cookies));
}
if let Some(cf_clearance) = entry.cf_clearance.as_ref() {
auth_config
.entry("cf_clearance".to_string())
.or_insert_with(|| json!(cf_clearance));
}
if let Some(user_agent) = entry.user_agent.as_ref() {
auth_config
.entry("user_agent".to_string())
.or_insert_with(|| json!(user_agent));
}
if let Some(browser_profile) = entry.browser_profile.as_ref() {
auth_config
.entry("browser_profile".to_string())
.or_insert_with(|| json!(browser_profile));
}
}
pub(super) async fn extract_admin_provider_oauth_batch_error_detail(
@@ -337,6 +528,7 @@ mod tests {
#[test]
fn parses_access_token_only_entry() {
let entries = parse_admin_provider_oauth_batch_import_entries(
"codex",
r#"[{"accessToken":"at_1","expiresAt":2100000000,"accountId":"acc-1","email":"[email protected]"}]"#,
);
@@ -356,10 +548,89 @@ mod tests {
"exp": 2_000_000_000u64,
}));
let entries = parse_admin_provider_oauth_batch_import_entries(&token);
let entries = parse_admin_provider_oauth_batch_import_entries("codex", &token);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].refresh_token, None);
assert_eq!(entries[0].access_token.as_deref(), Some(token.as_str()));
}
#[test]
fn parses_grok_jsonl_session_entries() {
let entries = parse_admin_provider_oauth_batch_import_entries(
"grok",
r#"{"sso_token":"sso-1","cf_clearance":"cf-1","pool_tier":"heavy","email":"[email protected]","browser_profile":"chrome136"}"#,
);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].refresh_token, None);
assert_eq!(entries[0].access_token.as_deref(), Some("sso-1"));
assert_eq!(entries[0].cf_clearance.as_deref(), Some("cf-1"));
assert_eq!(entries[0].pool_tier.as_deref(), Some("heavy"));
assert_eq!(entries[0].email.as_deref(), Some("[email protected]"));
assert_eq!(entries[0].browser_profile.as_deref(), Some("chrome136"));
}
#[test]
fn parses_grok_token_alias_with_account_traits() {
let entries = parse_admin_provider_oauth_batch_import_entries(
"grok",
r#"[{"token":"sso-1","planType":"super","tier":"heavy","accountName":"Grok Heavy"}]"#,
);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].refresh_token, None);
assert_eq!(entries[0].access_token.as_deref(), Some("sso-1"));
assert_eq!(entries[0].plan_type.as_deref(), Some("super"));
assert_eq!(entries[0].pool_tier.as_deref(), Some("heavy"));
assert_eq!(entries[0].account_name.as_deref(), Some("Grok Heavy"));
}
#[test]
fn parses_grok_plain_line_as_session_token() {
let entries = parse_admin_provider_oauth_batch_import_entries("grok", "opaque-sso-token");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].refresh_token, None);
assert_eq!(entries[0].access_token.as_deref(), Some("opaque-sso-token"));
}
#[test]
fn parses_grok_cookie_line_as_session_metadata() {
let entries = parse_admin_provider_oauth_batch_import_entries(
"grok",
"i18nextLng=zh; cf_clearance=cf-1; sso-rw=rw-1; sso=sso-1; x-userid=user-1",
);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].refresh_token, None);
assert_eq!(entries[0].access_token.as_deref(), Some("sso-1"));
assert_eq!(entries[0].sso_rw_token.as_deref(), Some("rw-1"));
assert_eq!(
entries[0].cf_cookies.as_deref(),
Some("i18nextLng=zh; cf_clearance=cf-1; x-userid=user-1")
);
assert_eq!(entries[0].cf_clearance.as_deref(), Some("cf-1"));
assert_eq!(entries[0].user_id.as_deref(), Some("user-1"));
}
#[test]
fn parses_grok_cookie_object_as_session_metadata() {
let entries = parse_admin_provider_oauth_batch_import_entries(
"grok",
r#"[{"cookie":"cf_clearance=cf-1; sso-rw=rw-1; sso=sso-1; x-userid=user-1","tier":"heavy"}]"#,
);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].refresh_token, None);
assert_eq!(entries[0].access_token.as_deref(), Some("sso-1"));
assert_eq!(entries[0].sso_rw_token.as_deref(), Some("rw-1"));
assert_eq!(
entries[0].cf_cookies.as_deref(),
Some("cf_clearance=cf-1; x-userid=user-1")
);
assert_eq!(entries[0].cf_clearance.as_deref(), Some("cf-1"));
assert_eq!(entries[0].user_id.as_deref(), Some("user-1"));
assert_eq!(entries[0].pool_tier.as_deref(), Some("heavy"));
}
}
@@ -10,7 +10,7 @@ use super::progress::{
};
use crate::handlers::admin::provider::oauth::errors::build_internal_control_error_response;
use crate::handlers::admin::provider::oauth::state::{
admin_provider_oauth_template, build_admin_provider_oauth_backend_unavailable_response,
build_admin_provider_oauth_backend_unavailable_response,
is_fixed_provider_type_for_provider_oauth,
};
use crate::handlers::admin::provider::shared::paths::admin_provider_oauth_batch_import_task_provider_id;
@@ -124,10 +124,6 @@ pub(in super::super) async fn handle_admin_provider_oauth_start_batch_import_tas
"该 Provider 不是固定类型,无法使用 provider-oauth",
));
}
if provider_type != "kiro" && admin_provider_oauth_template(&provider_type).is_none() {
return Ok(build_admin_provider_oauth_backend_unavailable_response());
}
let total = estimate_admin_provider_oauth_batch_import_total(
&provider_type,
payload.credentials.as_str(),
@@ -3,6 +3,8 @@ use axum::{
body::Body,
response::{IntoResponse, Response},
};
use serde_json::{Map, Value};
use std::time::{SystemTime, UNIX_EPOCH};
pub(super) fn attach_admin_provider_oauth_audit_response(
response: Response<Body>,
@@ -19,3 +21,79 @@ pub(super) fn attach_admin_provider_oauth_audit_response(
};
attach_admin_audit_response(response, event_name, action, target_type, &target_id)
}
pub(super) fn admin_provider_oauth_key_name_from_auth_config(
provider_type: &str,
auth_config: &Map<String, Value>,
batch_index: Option<usize>,
) -> String {
let provider_type = provider_type.trim();
if let Some(email) = trimmed_auth_config_string(auth_config, "email") {
return format!("{provider_type}_{email}");
}
if provider_type.eq_ignore_ascii_case("grok") {
if let Some(user_id) = trimmed_auth_config_string(auth_config, "user_id") {
return format!("grok_{user_id}");
}
}
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs())
.unwrap_or(0);
match batch_index {
Some(index) => format!("{provider_type}_{timestamp}_{index}"),
None => format!("账号_{timestamp}"),
}
}
fn trimmed_auth_config_string(auth_config: &Map<String, Value>, key: &str) -> Option<String> {
auth_config
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{json, Map};
#[test]
fn grok_default_key_name_uses_full_user_id() {
let mut auth_config = Map::new();
auth_config.insert(
"user_id".to_string(),
json!("1619039a-0191-4e0a-a490-8f4ad21262c9"),
);
assert_eq!(
admin_provider_oauth_key_name_from_auth_config("grok", &auth_config, None),
"grok_1619039a-0191-4e0a-a490-8f4ad21262c9"
);
}
#[test]
fn default_key_name_prefers_email_over_grok_user_id() {
let mut auth_config = Map::new();
auth_config.insert("email".to_string(), json!("[email protected]"));
auth_config.insert("user_id".to_string(), json!("user-1"));
assert_eq!(
admin_provider_oauth_key_name_from_auth_config("grok", &auth_config, None),
"[email protected]"
);
}
#[test]
fn batch_default_key_name_keeps_existing_timestamp_shape() {
let auth_config = Map::new();
let name = admin_provider_oauth_key_name_from_auth_config("codex", &auth_config, Some(3));
assert!(name.starts_with("codex_"));
assert!(name.ends_with("_3"));
}
}
@@ -14,8 +14,9 @@ use super::super::state::{
exchange_admin_provider_oauth_refresh_token, is_fixed_provider_type_for_provider_oauth,
json_u64_value,
};
use super::helpers::admin_provider_oauth_key_name_from_auth_config;
use super::token_import::{
build_provider_access_token_import_auth_config, normalize_single_import_tokens,
build_provider_access_token_import_auth_config, normalize_provider_import_tokens,
provider_type_supports_access_token_import,
};
use crate::handlers::admin::provider::shared::paths::admin_provider_oauth_import_provider_id;
@@ -31,7 +32,6 @@ use axum::{
Json,
};
use serde_json::json;
use std::time::{SystemTime, UNIX_EPOCH};
struct AdminProviderOAuthSingleImportTokens {
access_token: String,
@@ -72,7 +72,8 @@ fn apply_single_import_hints(
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) {
let provider_type = provider_type.trim().to_ascii_lowercase();
if !matches!(provider_type.as_str(), "codex" | "chatgpt_web" | "grok") {
return;
}
@@ -110,19 +111,40 @@ fn apply_single_import_hints(
&["user_id", "userId", "chatgpt_user_id", "chatgptUserId"][..],
),
("account_name", &["account_name", "accountName"][..]),
("sso_rw_token", &["sso_rw_token", "ssoRwToken"][..]),
(
"cf_cookies",
&["cf_cookies", "cfCookies", "cookie", "cookieHeader"][..],
),
("cf_clearance", &["cf_clearance", "cfClearance"][..]),
("user_agent", &["user_agent", "userAgent"][..]),
(
"browser_profile",
&[
"browser_profile",
"browserProfile",
"browser",
"impersonate",
][..],
),
("pool_tier", &["pool_tier", "poolTier", "tier"][..]),
] {
let Some(value) = import_payload_string_any(payload, keys) else {
continue;
};
auth_config
.entry(target.to_string())
.or_insert_with(|| json!(value));
auth_config.entry(target.to_string()).or_insert_with(|| {
if target == "plan_type" || target == "pool_tier" {
json!(value.to_ascii_lowercase())
} else {
json!(value)
}
});
}
}
async fn resolve_admin_provider_oauth_single_import_tokens(
state: &AdminAppState<'_>,
template: AdminProviderOAuthTemplate,
template: Option<AdminProviderOAuthTemplate>,
provider_type: &str,
refresh_token: Option<&str>,
access_token: Option<&str>,
@@ -133,6 +155,32 @@ async fn resolve_admin_provider_oauth_single_import_tokens(
.map(str::trim)
.filter(|value| !value.is_empty())
{
let Some(template) = template else {
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_provider_access_token_import_auth_config(
provider_type,
access_token,
Some(refresh_token),
imported_expires_at,
Some("Provider 不支持 Refresh Token 交换,已回退为 Session Token 导入"),
);
return Ok(AdminProviderOAuthSingleImportTokens {
access_token: access_token.to_string(),
auth_config,
expires_at,
});
}
}
return Err(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"该 Provider 不支持 Refresh Token 导入,请提供 sso_token 或 access_token",
));
};
let token_payload = match state
.exchange_admin_provider_oauth_refresh_token(
template,
@@ -200,7 +248,7 @@ async fn resolve_admin_provider_oauth_single_import_tokens(
if !provider_type_supports_access_token_import(provider_type) {
return Err(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"Access Token 导入仅支持 Codex / ChatGPT Web Provider",
"Access Token 导入仅支持 Codex / ChatGPT Web / Grok Provider",
));
}
@@ -248,18 +296,11 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
}
};
let refresh_token_input = import_payload_string(&raw_payload, "refresh_token", "refreshToken");
let access_token_input = import_payload_string(&raw_payload, "access_token", "accessToken");
let imported_expires_at = import_payload_u64(&raw_payload, "expires_at", "expiresAt");
let (refresh_token_input, access_token_input) = normalize_single_import_tokens(
refresh_token_input.as_deref(),
access_token_input.as_deref(),
let access_token_input = import_payload_string_any(
&raw_payload,
&["access_token", "accessToken", "sso_token", "ssoToken"],
);
if refresh_token_input.is_none() && access_token_input.is_none() {
return Ok(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"Refresh Token 或 Access Token 不能为空",
));
}
let imported_expires_at = import_payload_u64(&raw_payload, "expires_at", "expiresAt");
let name = raw_payload
.get("name")
.and_then(serde_json::Value::as_str)
@@ -285,6 +326,17 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
));
};
let provider_type = provider.provider_type.trim().to_ascii_lowercase();
let (refresh_token_input, access_token_input) = normalize_provider_import_tokens(
&provider_type,
refresh_token_input.as_deref(),
access_token_input.as_deref(),
);
if refresh_token_input.is_none() && access_token_input.is_none() {
return Ok(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"Refresh Token、Access Token 或 sso_token 不能为空",
));
}
if !is_fixed_provider_type_for_provider_oauth(&provider_type) {
return Ok(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
@@ -297,9 +349,10 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
"Kiro 不支持单条 Refresh Token 导入,请使用批量导入或设备授权。",
));
}
let Some(template) = admin_provider_oauth_template(&provider_type) else {
let template = admin_provider_oauth_template(&provider_type);
if template.is_none() && !provider_type_supports_access_token_import(&provider_type) {
return Ok(build_admin_provider_oauth_backend_unavailable_response());
};
}
let endpoint_resolution =
resolve_provider_oauth_runtime_endpoints(state, &provider, &provider_type).await?;
let endpoints = endpoint_resolution.endpoints;
@@ -380,25 +433,9 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
}
}
} else {
let name = name
.or_else(|| {
auth_config
.get("email")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.unwrap_or_else(|| {
format!(
"账号_{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs())
.unwrap_or(0)
)
});
let name = name.unwrap_or_else(|| {
admin_provider_oauth_key_name_from_auth_config(&provider_type, &auth_config, None)
});
match state
.create_provider_oauth_catalog_key(
&provider_id,
@@ -81,6 +81,28 @@ pub(super) fn normalize_single_import_tokens(
(refresh_token, access_token)
}
pub(super) fn normalize_provider_import_tokens(
provider_type: &str,
refresh_token: Option<&str>,
access_token: Option<&str>,
) -> (Option<String>, Option<String>) {
let provider_type = provider_type.trim().to_ascii_lowercase();
let refresh_token = refresh_token
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let access_token = access_token
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
if provider_type == "grok" {
return (None, access_token.or(refresh_token));
}
normalize_single_import_tokens(refresh_token.as_deref(), access_token.as_deref())
}
pub(super) fn import_tokens_from_raw_token(token: &str) -> (Option<String>, Option<String>) {
if looks_like_access_token(token) {
(None, Some(token.trim().to_string()))
@@ -98,7 +120,7 @@ pub(super) fn decode_access_token_expires_at(access_token: &str) -> Option<u64>
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"
"codex" | "chatgpt_web" | "grok"
)
}
@@ -123,6 +145,11 @@ pub(super) fn build_provider_access_token_import_auth_config(
auth_config.insert("refresh_token".to_string(), json!(refresh_token));
}
if provider_type.trim().eq_ignore_ascii_case("grok") {
auth_config.insert("sso_token".to_string(), json!(access_token));
auth_config.insert("auth_method".to_string(), json!("sso_token"));
}
auth_config.insert(
"access_token_import_temporary".to_string(),
json!(refresh_token.is_none()),
@@ -149,7 +176,7 @@ pub(super) fn build_provider_access_token_import_auth_config(
mod tests {
use super::{
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_provider_import_tokens, normalize_single_import_tokens,
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use serde_json::json;
@@ -250,4 +277,34 @@ mod tests {
Some(&json!(true))
);
}
#[test]
fn normalize_grok_import_treats_opaque_session_as_access_token() {
let (refresh_token, access_token) =
normalize_provider_import_tokens("grok", Some("sso_session_token"), None);
assert!(refresh_token.is_none());
assert_eq!(access_token.as_deref(), Some("sso_session_token"));
}
#[test]
fn builds_grok_auth_config_from_session_token() {
let (auth_config, expires_at) = build_provider_access_token_import_auth_config(
"grok",
"sso_session_token",
None,
Some(2_200_000_000),
None,
);
assert_eq!(expires_at, Some(2_200_000_000));
assert_eq!(
auth_config.get("sso_token"),
Some(&json!("sso_session_token"))
);
assert_eq!(auth_config.get("auth_method"), Some(&json!("sso_token")));
assert_eq!(
auth_config.get("expires_at"),
Some(&json!(2_200_000_000u64))
);
}
}