mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
feat: improve OAuth provider configuration
This commit is contained in:
@@ -41,5 +41,9 @@ fn git_describe_version() -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_version(value: &str) -> String {
|
fn normalize_version(value: &str) -> String {
|
||||||
value.trim().strip_prefix('v').unwrap_or(value.trim()).to_string()
|
value
|
||||||
|
.trim()
|
||||||
|
.strip_prefix('v')
|
||||||
|
.unwrap_or(value.trim())
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,11 +87,36 @@ pub(crate) fn admin_oauth_test_provider_type_from_path(request_path: &str) -> Op
|
|||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn admin_oauth_is_supported_provider(provider_type: &str) -> bool {
|
pub(super) fn admin_oauth_normalized_provider_type(provider_type: &str) -> Option<String> {
|
||||||
matches!(
|
let normalized = provider_type.trim().to_ascii_lowercase();
|
||||||
provider_type.to_ascii_lowercase().as_str(),
|
if !(3..=64).contains(&normalized.len()) {
|
||||||
"linuxdo" | "custom_oidc"
|
return None;
|
||||||
)
|
}
|
||||||
|
let mut chars = normalized.chars();
|
||||||
|
let first = chars.next()?;
|
||||||
|
if !first.is_ascii_lowercase() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' || ch == '-') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn admin_oauth_is_custom_provider_type(provider_type: &str) -> bool {
|
||||||
|
let Some(provider_type) = admin_oauth_normalized_provider_type(provider_type) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
provider_type == "custom_oidc"
|
||||||
|
|| provider_type.starts_with("custom_oidc_")
|
||||||
|
|| provider_type.starts_with("custom_")
|
||||||
|
|| provider_type.starts_with("oidc_")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn admin_oauth_is_supported_provider(provider_type: &str) -> bool {
|
||||||
|
admin_oauth_normalized_provider_type(provider_type).is_some_and(|provider_type| {
|
||||||
|
provider_type == "linuxdo" || admin_oauth_is_custom_provider_type(&provider_type)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn admin_oauth_builtin_allowed_domains(provider_type: &str) -> Option<&'static [&'static str]> {
|
fn admin_oauth_builtin_allowed_domains(provider_type: &str) -> Option<&'static [&'static str]> {
|
||||||
@@ -184,7 +209,10 @@ pub(super) fn build_admin_oauth_upsert_record(
|
|||||||
provider_type: &str,
|
provider_type: &str,
|
||||||
payload: AdminOAuthProviderUpsertRequest,
|
payload: AdminOAuthProviderUpsertRequest,
|
||||||
) -> Result<UpsertOAuthProviderConfigRecord, String> {
|
) -> Result<UpsertOAuthProviderConfigRecord, String> {
|
||||||
if !admin_oauth_is_supported_provider(provider_type) {
|
let Some(provider_type) = admin_oauth_normalized_provider_type(provider_type) else {
|
||||||
|
return Err("provider_type 只能包含小写字母、数字、下划线和中划线".to_string());
|
||||||
|
};
|
||||||
|
if !admin_oauth_is_supported_provider(&provider_type) {
|
||||||
return Err("不支持的 provider_type".to_string());
|
return Err("不支持的 provider_type".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,19 +236,19 @@ pub(super) fn build_admin_oauth_upsert_record(
|
|||||||
validate_admin_oauth_frontend_callback_url(frontend_callback_url)?;
|
validate_admin_oauth_frontend_callback_url(frontend_callback_url)?;
|
||||||
validate_admin_oauth_redirect_uri(redirect_uri)?;
|
validate_admin_oauth_redirect_uri(redirect_uri)?;
|
||||||
|
|
||||||
let is_custom_oidc = provider_type.eq_ignore_ascii_case("custom_oidc");
|
let is_custom_oidc = admin_oauth_is_custom_provider_type(&provider_type);
|
||||||
let custom_allowed_domains = if is_custom_oidc {
|
let custom_allowed_domains = if is_custom_oidc {
|
||||||
let domains = admin_oauth_custom_allowed_domains(payload.extra_config.as_ref());
|
let domains = admin_oauth_custom_allowed_domains(payload.extra_config.as_ref());
|
||||||
if domains.is_empty() {
|
if domains.is_empty() {
|
||||||
return Err(
|
return Err(format!(
|
||||||
"custom_oidc 必须在 extra_config.allowed_domains 配置域名白名单".to_string(),
|
"{provider_type} 必须在 extra_config.allowed_domains 配置域名白名单"
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
domains
|
domains
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
let builtin_allowed_domains = admin_oauth_builtin_allowed_domains(provider_type);
|
let builtin_allowed_domains = admin_oauth_builtin_allowed_domains(&provider_type);
|
||||||
|
|
||||||
if is_custom_oidc {
|
if is_custom_oidc {
|
||||||
for (field_name, value) in [
|
for (field_name, value) in [
|
||||||
@@ -309,7 +337,7 @@ pub(super) fn build_admin_oauth_upsert_record(
|
|||||||
};
|
};
|
||||||
|
|
||||||
Ok(UpsertOAuthProviderConfigRecord {
|
Ok(UpsertOAuthProviderConfigRecord {
|
||||||
provider_type: provider_type.to_string(),
|
provider_type,
|
||||||
display_name: display_name.to_string(),
|
display_name: display_name.to_string(),
|
||||||
client_id: client_id.to_string(),
|
client_id: client_id.to_string(),
|
||||||
client_secret_encrypted,
|
client_secret_encrypted,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use super::oauth_config::{
|
use super::oauth_config::{
|
||||||
admin_oauth_provider_type_from_path, admin_oauth_test_provider_type_from_path,
|
admin_oauth_is_supported_provider, admin_oauth_provider_type_from_path,
|
||||||
build_admin_oauth_provider_payload, build_admin_oauth_supported_types_payload,
|
admin_oauth_test_provider_type_from_path, build_admin_oauth_provider_payload,
|
||||||
build_admin_oauth_upsert_record, AdminOAuthProviderUpsertRequest,
|
build_admin_oauth_supported_types_payload, build_admin_oauth_upsert_record,
|
||||||
|
AdminOAuthProviderUpsertRequest,
|
||||||
};
|
};
|
||||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||||
use crate::handlers::admin::shared::{attach_admin_audit_response, build_proxy_error_response};
|
use crate::handlers::admin::shared::{attach_admin_audit_response, build_proxy_error_response};
|
||||||
@@ -13,6 +14,143 @@ use axum::{
|
|||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const ADMIN_OAUTH_TEST_TIMEOUT_SECS: u64 = 5;
|
||||||
|
const LINUXDO_AUTHORIZATION_URL: &str = "https://connect.linux.do/oauth2/authorize";
|
||||||
|
const LINUXDO_TOKEN_URL: &str = "https://connect.linux.do/oauth2/token";
|
||||||
|
|
||||||
|
fn admin_oauth_payload_string<'a>(payload: &'a serde_json::Value, field: &str) -> Option<&'a str> {
|
||||||
|
payload
|
||||||
|
.get(field)
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn admin_oauth_secret_status(has_secret: bool) -> &'static str {
|
||||||
|
if has_secret {
|
||||||
|
"configured"
|
||||||
|
} else {
|
||||||
|
"not_provided"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn admin_oauth_endpoint_reachable(client: &reqwest::Client, url: &str) -> bool {
|
||||||
|
let Ok(parsed) = reqwest::Url::parse(url) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
match client
|
||||||
|
.get(parsed)
|
||||||
|
.header(reqwest::header::ACCEPT, "*/*")
|
||||||
|
.header(
|
||||||
|
reqwest::header::USER_AGENT,
|
||||||
|
"Aether OAuth configuration tester",
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(response) => {
|
||||||
|
let status = response.status();
|
||||||
|
status != reqwest::StatusCode::NOT_FOUND && status.as_u16() < 500
|
||||||
|
}
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_admin_oauth_test_payload(
|
||||||
|
state: &AdminAppState<'_>,
|
||||||
|
provider_type: &str,
|
||||||
|
payload: &serde_json::Value,
|
||||||
|
) -> Result<serde_json::Value, GatewayError> {
|
||||||
|
if !admin_oauth_is_supported_provider(provider_type) {
|
||||||
|
return Ok(json!({
|
||||||
|
"authorization_url_reachable": false,
|
||||||
|
"token_url_reachable": false,
|
||||||
|
"secret_status": "unknown",
|
||||||
|
"details": "provider 未安装/不可用",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let provided_secret = admin_oauth_payload_string(payload, "client_secret");
|
||||||
|
let persisted_config = state.get_oauth_provider_config(provider_type).await?;
|
||||||
|
let persisted_secret_configured = persisted_config
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|provider| provider.client_secret_encrypted.as_ref())
|
||||||
|
.is_some();
|
||||||
|
let has_secret = provided_secret.is_some() || persisted_secret_configured;
|
||||||
|
|
||||||
|
let builtin_defaults = provider_type
|
||||||
|
.eq_ignore_ascii_case("linuxdo")
|
||||||
|
.then_some((LINUXDO_AUTHORIZATION_URL, LINUXDO_TOKEN_URL));
|
||||||
|
let authorization_url = admin_oauth_payload_string(payload, "authorization_url_override")
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.or_else(|| {
|
||||||
|
persisted_config
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|provider| provider.authorization_url_override.clone())
|
||||||
|
})
|
||||||
|
.or_else(|| builtin_defaults.map(|defaults| defaults.0.to_string()));
|
||||||
|
let token_url = admin_oauth_payload_string(payload, "token_url_override")
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.or_else(|| {
|
||||||
|
persisted_config
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|provider| provider.token_url_override.clone())
|
||||||
|
})
|
||||||
|
.or_else(|| builtin_defaults.map(|defaults| defaults.1.to_string()));
|
||||||
|
let Some(authorization_url) = authorization_url else {
|
||||||
|
return Ok(json!({
|
||||||
|
"authorization_url_reachable": false,
|
||||||
|
"token_url_reachable": false,
|
||||||
|
"secret_status": admin_oauth_secret_status(has_secret),
|
||||||
|
"details": "Authorization URL 未配置",
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
let Some(token_url) = token_url else {
|
||||||
|
return Ok(json!({
|
||||||
|
"authorization_url_reachable": false,
|
||||||
|
"token_url_reachable": false,
|
||||||
|
"secret_status": admin_oauth_secret_status(has_secret),
|
||||||
|
"details": "Token URL 未配置",
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(ADMIN_OAUTH_TEST_TIMEOUT_SECS))
|
||||||
|
.redirect(reqwest::redirect::Policy::limited(3))
|
||||||
|
.build();
|
||||||
|
let Ok(client) = client else {
|
||||||
|
return Ok(json!({
|
||||||
|
"authorization_url_reachable": false,
|
||||||
|
"token_url_reachable": false,
|
||||||
|
"secret_status": admin_oauth_secret_status(has_secret),
|
||||||
|
"details": "OAuth 配置测试 HTTP client 初始化失败",
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
let (authorization_url_reachable, token_url_reachable) = tokio::join!(
|
||||||
|
admin_oauth_endpoint_reachable(&client, &authorization_url),
|
||||||
|
admin_oauth_endpoint_reachable(&client, &token_url),
|
||||||
|
);
|
||||||
|
|
||||||
|
let details = if authorization_url_reachable && token_url_reachable {
|
||||||
|
"OAuth 端点可达;client_secret 仅在授权回调兑换 code 时校验"
|
||||||
|
} else {
|
||||||
|
"OAuth 端点不可达或返回不可用状态;请检查端点 URL、网络和代理配置"
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"authorization_url_reachable": authorization_url_reachable,
|
||||||
|
"token_url_reachable": token_url_reachable,
|
||||||
|
"secret_status": admin_oauth_secret_status(has_secret),
|
||||||
|
"details": details,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn maybe_build_local_admin_oauth_response(
|
pub(crate) async fn maybe_build_local_admin_oauth_response(
|
||||||
state: &AdminAppState<'_>,
|
state: &AdminAppState<'_>,
|
||||||
@@ -259,16 +397,8 @@ pub(crate) async fn maybe_build_local_admin_oauth_response(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let client_id = payload
|
let client_id = admin_oauth_payload_string(&payload, "client_id");
|
||||||
.get("client_id")
|
let redirect_uri = admin_oauth_payload_string(&payload, "redirect_uri");
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty());
|
|
||||||
let redirect_uri = payload
|
|
||||||
.get("redirect_uri")
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty());
|
|
||||||
if client_id.is_none() || redirect_uri.is_none() {
|
if client_id.is_none() || redirect_uri.is_none() {
|
||||||
return Ok(Some(
|
return Ok(Some(
|
||||||
(
|
(
|
||||||
@@ -278,38 +408,9 @@ pub(crate) async fn maybe_build_local_admin_oauth_response(
|
|||||||
.into_response(),
|
.into_response(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let provided_secret = payload
|
let test_payload = build_admin_oauth_test_payload(state, &provider_type, &payload).await?;
|
||||||
.get("client_secret")
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty());
|
|
||||||
let persisted_secret = state
|
|
||||||
.get_oauth_provider_config(&provider_type)
|
|
||||||
.await?
|
|
||||||
.and_then(|provider| provider.client_secret_encrypted);
|
|
||||||
let supported_provider = provider_type.eq_ignore_ascii_case("linuxdo");
|
|
||||||
let secret_status = if supported_provider {
|
|
||||||
if provided_secret.is_some() || persisted_secret.is_some() {
|
|
||||||
"unsupported"
|
|
||||||
} else {
|
|
||||||
"not_provided"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
"unknown"
|
|
||||||
};
|
|
||||||
let details = if supported_provider {
|
|
||||||
"OAuth 配置测试仅支持 Rust execution runtime"
|
|
||||||
} else {
|
|
||||||
"provider 未安装/不可用"
|
|
||||||
};
|
|
||||||
return Ok(Some(attach_admin_audit_response(
|
return Ok(Some(attach_admin_audit_response(
|
||||||
Json(json!({
|
Json(test_payload).into_response(),
|
||||||
"authorization_url_reachable": false,
|
|
||||||
"token_url_reachable": false,
|
|
||||||
"secret_status": secret_status,
|
|
||||||
"details": details,
|
|
||||||
}))
|
|
||||||
.into_response(),
|
|
||||||
"admin_oauth_provider_tested",
|
"admin_oauth_provider_tested",
|
||||||
"test_oauth_provider_config",
|
"test_oauth_provider_config",
|
||||||
"oauth_provider",
|
"oauth_provider",
|
||||||
|
|||||||
@@ -6197,6 +6197,232 @@ async fn gateway_upserts_custom_oidc_with_allowed_domains() {
|
|||||||
upstream_handle.abort();
|
upstream_handle.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_upserts_multiple_custom_oidc_configs() {
|
||||||
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||||
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||||
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
||||||
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
||||||
|
async move {
|
||||||
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
||||||
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let repository = Arc::new(InMemoryOAuthProviderRepository::default());
|
||||||
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||||
|
let gateway = build_router_with_state(
|
||||||
|
AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(GatewayDataState::with_oauth_provider_repository_for_tests(
|
||||||
|
repository.clone(),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
|
for (provider_type, display_name, host) in [
|
||||||
|
("custom_oidc_work", "Work OIDC", "work-idp.example.com"),
|
||||||
|
(
|
||||||
|
"custom_oidc_personal",
|
||||||
|
"Personal OIDC",
|
||||||
|
"personal-idp.example.com",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.put(format!(
|
||||||
|
"{gateway_url}/api/admin/oauth/providers/{provider_type}"
|
||||||
|
))
|
||||||
|
.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!({
|
||||||
|
"display_name": display_name,
|
||||||
|
"client_id": format!("{provider_type}-client"),
|
||||||
|
"authorization_url_override": format!("https://{host}/oauth/authorize"),
|
||||||
|
"token_url_override": format!("https://{host}/oauth/token"),
|
||||||
|
"userinfo_url_override": format!("https://{host}/oauth/userinfo"),
|
||||||
|
"scopes": ["openid", "profile", "email"],
|
||||||
|
"redirect_uri": format!("https://backend.example.com/api/oauth/{provider_type}/callback"),
|
||||||
|
"frontend_callback_url": "https://frontend.example.com/auth/callback",
|
||||||
|
"attribute_mapping": {"sub": "id", "email": "profile.email"},
|
||||||
|
"extra_config": {"allowed_domains": [host]},
|
||||||
|
"is_enabled": true
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
|
assert_eq!(payload["provider_type"], provider_type);
|
||||||
|
assert_eq!(payload["display_name"], display_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stored = repository
|
||||||
|
.list_oauth_provider_configs()
|
||||||
|
.await
|
||||||
|
.expect("list should succeed");
|
||||||
|
assert_eq!(stored.len(), 2);
|
||||||
|
assert!(stored
|
||||||
|
.iter()
|
||||||
|
.any(|provider| provider.provider_type == "custom_oidc_work"));
|
||||||
|
assert!(stored
|
||||||
|
.iter()
|
||||||
|
.any(|provider| provider.provider_type == "custom_oidc_personal"));
|
||||||
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
upstream_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_tests_admin_oauth_linuxdo_endpoints_locally_with_configured_secret() {
|
||||||
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||||
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||||
|
let upstream = Router::new().route(
|
||||||
|
"/api/admin/oauth/providers/linuxdo/test",
|
||||||
|
any(move |_request: Request| {
|
||||||
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
||||||
|
async move {
|
||||||
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
||||||
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let authorization_hits = Arc::new(Mutex::new(0usize));
|
||||||
|
let authorization_hits_clone = Arc::clone(&authorization_hits);
|
||||||
|
let token_hits = Arc::new(Mutex::new(0usize));
|
||||||
|
let token_hits_clone = Arc::clone(&token_hits);
|
||||||
|
let oauth_endpoints = Router::new()
|
||||||
|
.route(
|
||||||
|
"/oauth2/authorize",
|
||||||
|
any(move |_request: Request| {
|
||||||
|
let authorization_hits_inner = Arc::clone(&authorization_hits_clone);
|
||||||
|
async move {
|
||||||
|
*authorization_hits_inner.lock().expect("mutex should lock") += 1;
|
||||||
|
(StatusCode::BAD_REQUEST, Body::from("missing query"))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/oauth2/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::METHOD_NOT_ALLOWED,
|
||||||
|
Body::from("method not allowed"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let repository = Arc::new(InMemoryOAuthProviderRepository::seed(vec![
|
||||||
|
sample_oauth_provider_config("linuxdo"),
|
||||||
|
]));
|
||||||
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||||
|
let (oauth_url, oauth_handle) = start_server(oauth_endpoints).await;
|
||||||
|
let gateway = build_router_with_state(
|
||||||
|
AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(GatewayDataState::with_oauth_provider_repository_for_tests(
|
||||||
|
repository,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(format!(
|
||||||
|
"{gateway_url}/api/admin/oauth/providers/linuxdo/test"
|
||||||
|
))
|
||||||
|
.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!({
|
||||||
|
"client_id": "client-id",
|
||||||
|
"authorization_url_override": format!("{oauth_url}/oauth2/authorize"),
|
||||||
|
"token_url_override": format!("{oauth_url}/oauth2/token"),
|
||||||
|
"redirect_uri": "http://localhost:8084/api/oauth/linuxdo/callback"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
|
assert_eq!(payload["authorization_url_reachable"], true);
|
||||||
|
assert_eq!(payload["token_url_reachable"], true);
|
||||||
|
assert_eq!(payload["secret_status"], "configured");
|
||||||
|
assert_eq!(*authorization_hits.lock().expect("mutex should lock"), 1);
|
||||||
|
assert_eq!(*token_hits.lock().expect("mutex should lock"), 1);
|
||||||
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
upstream_handle.abort();
|
||||||
|
oauth_handle.abort();
|
||||||
|
let _ = upstream_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_tests_admin_oauth_linuxdo_reports_invalid_endpoint_urls() {
|
||||||
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||||
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||||
|
let upstream = Router::new().route(
|
||||||
|
"/api/admin/oauth/providers/linuxdo/test",
|
||||||
|
any(move |_request: Request| {
|
||||||
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
||||||
|
async move {
|
||||||
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
||||||
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let repository = Arc::new(InMemoryOAuthProviderRepository::default());
|
||||||
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||||
|
let gateway = build_router_with_state(
|
||||||
|
AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(GatewayDataState::with_oauth_provider_repository_for_tests(
|
||||||
|
repository,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(format!(
|
||||||
|
"{gateway_url}/api/admin/oauth/providers/linuxdo/test"
|
||||||
|
))
|
||||||
|
.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!({
|
||||||
|
"client_id": "client-id",
|
||||||
|
"authorization_url_override": "not-a-url",
|
||||||
|
"token_url_override": "not-a-url",
|
||||||
|
"redirect_uri": "http://localhost:8084/api/oauth/linuxdo/callback"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
|
assert_eq!(payload["authorization_url_reachable"], false);
|
||||||
|
assert_eq!(payload["token_url_reachable"], false);
|
||||||
|
assert_eq!(payload["secret_status"], "not_provided");
|
||||||
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
upstream_handle.abort();
|
||||||
|
let _ = upstream_url;
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_deletes_admin_oauth_provider_locally_with_trusted_admin_principal() {
|
async fn gateway_deletes_admin_oauth_provider_locally_with_trusted_admin_principal() {
|
||||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ impl IdentityOAuthService {
|
|||||||
) -> Result<Arc<dyn IdentityOAuthProvider>, OAuthError> {
|
) -> Result<Arc<dyn IdentityOAuthProvider>, OAuthError> {
|
||||||
self.registry
|
self.registry
|
||||||
.get(provider_type)
|
.get(provider_type)
|
||||||
|
.or_else(|| {
|
||||||
|
is_custom_oidc_provider_type(provider_type)
|
||||||
|
.then(|| self.registry.get("custom_oidc"))
|
||||||
|
.flatten()
|
||||||
|
})
|
||||||
.ok_or_else(|| OAuthError::UnsupportedProvider(provider_type.to_string()))
|
.ok_or_else(|| OAuthError::UnsupportedProvider(provider_type.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +85,14 @@ impl IdentityOAuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_custom_oidc_provider_type(provider_type: &str) -> bool {
|
||||||
|
let normalized = provider_type.trim().to_ascii_lowercase();
|
||||||
|
normalized == "custom_oidc"
|
||||||
|
|| normalized.starts_with("custom_oidc_")
|
||||||
|
|| normalized.starts_with("custom_")
|
||||||
|
|| normalized.starts_with("oidc_")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn start_identity_oauth(
|
pub fn start_identity_oauth(
|
||||||
provider: &dyn IdentityOAuthProvider,
|
provider: &dyn IdentityOAuthProvider,
|
||||||
config: &IdentityOAuthProviderConfig,
|
config: &IdentityOAuthProviderConfig,
|
||||||
@@ -132,6 +145,7 @@ mod tests {
|
|||||||
|
|
||||||
assert!(service.provider("linuxdo").is_ok());
|
assert!(service.provider("linuxdo").is_ok());
|
||||||
assert!(service.provider("custom_oidc").is_ok());
|
assert!(service.provider("custom_oidc").is_ok());
|
||||||
|
assert!(service.provider("custom_oidc_work").is_ok());
|
||||||
assert!(service.provider("missing").is_err());
|
assert!(service.provider("missing").is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export interface OAuthProviderUpsertRequest {
|
|||||||
export interface OAuthProviderTestResponse {
|
export interface OAuthProviderTestResponse {
|
||||||
authorization_url_reachable: boolean
|
authorization_url_reachable: boolean
|
||||||
token_url_reachable: boolean
|
token_url_reachable: boolean
|
||||||
secret_status: 'likely_valid' | 'invalid' | 'unknown' | 'not_provided' | string
|
secret_status: 'likely_valid' | 'configured' | 'invalid' | 'unknown' | 'not_provided' | string
|
||||||
details?: string
|
details?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,24 +118,23 @@ export const oauthApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async getProviderConfig(providerType: string): Promise<OAuthProviderAdminConfig> {
|
async getProviderConfig(providerType: string): Promise<OAuthProviderAdminConfig> {
|
||||||
const response = await apiClient.get<OAuthProviderAdminConfig>(`/api/admin/oauth/providers/${providerType}`)
|
const response = await apiClient.get<OAuthProviderAdminConfig>(`/api/admin/oauth/providers/${encodeURIComponent(providerType)}`)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
async upsertProviderConfig(providerType: string, payload: OAuthProviderUpsertRequest): Promise<OAuthProviderAdminConfig> {
|
async upsertProviderConfig(providerType: string, payload: OAuthProviderUpsertRequest): Promise<OAuthProviderAdminConfig> {
|
||||||
const response = await apiClient.put<OAuthProviderAdminConfig>(`/api/admin/oauth/providers/${providerType}`, payload)
|
const response = await apiClient.put<OAuthProviderAdminConfig>(`/api/admin/oauth/providers/${encodeURIComponent(providerType)}`, payload)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteProviderConfig(providerType: string): Promise<{ message: string }> {
|
async deleteProviderConfig(providerType: string): Promise<{ message: string }> {
|
||||||
const response = await apiClient.delete<{ message: string }>(`/api/admin/oauth/providers/${providerType}`)
|
const response = await apiClient.delete<{ message: string }>(`/api/admin/oauth/providers/${encodeURIComponent(providerType)}`)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
async testProviderConfig(providerType: string, payload: OAuthProviderTestRequest): Promise<OAuthProviderTestResponse> {
|
async testProviderConfig(providerType: string, payload: OAuthProviderTestRequest): Promise<OAuthProviderTestResponse> {
|
||||||
const response = await apiClient.post<OAuthProviderTestResponse>(`/api/admin/oauth/providers/${providerType}/test`, payload)
|
const response = await apiClient.post<OAuthProviderTestResponse>(`/api/admin/oauth/providers/${encodeURIComponent(providerType)}/test`, payload)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
53
frontend/src/utils/__tests__/oauthConfigTest.spec.ts
Normal file
53
frontend/src/utils/__tests__/oauthConfigTest.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { summarizeOAuthConfigTest } from '../oauthConfigTest'
|
||||||
|
|
||||||
|
describe('summarizeOAuthConfigTest', () => {
|
||||||
|
it('marks unreachable endpoints and unsupported secret as failed', () => {
|
||||||
|
const summary = summarizeOAuthConfigTest({
|
||||||
|
authorization_url_reachable: false,
|
||||||
|
token_url_reachable: false,
|
||||||
|
secret_status: 'unsupported',
|
||||||
|
details: 'OAuth 配置测试仅支持 Rust execution runtime',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(summary.severity).toBe('error')
|
||||||
|
expect(summary.failures).toEqual([
|
||||||
|
'Authorization URL 不可达',
|
||||||
|
'Token URL 不可达',
|
||||||
|
'Secret 不受支持',
|
||||||
|
])
|
||||||
|
expect(summary.message).toBe('测试失败:Authorization URL 不可达,Token URL 不可达,Secret 不受支持')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses warning when only secret validation is inconclusive', () => {
|
||||||
|
const summary = summarizeOAuthConfigTest({
|
||||||
|
authorization_url_reachable: true,
|
||||||
|
token_url_reachable: true,
|
||||||
|
secret_status: 'unknown',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(summary.severity).toBe('warning')
|
||||||
|
expect(summary.warnings).toEqual(['Secret 未验证'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks fully reachable config with a likely valid secret as successful', () => {
|
||||||
|
const summary = summarizeOAuthConfigTest({
|
||||||
|
authorization_url_reachable: true,
|
||||||
|
token_url_reachable: true,
|
||||||
|
secret_status: 'likely_valid',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(summary.severity).toBe('success')
|
||||||
|
expect(summary.message).toBe('测试通过')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts a configured secret because OAuth secrets are verified during code exchange', () => {
|
||||||
|
const summary = summarizeOAuthConfigTest({
|
||||||
|
authorization_url_reachable: true,
|
||||||
|
token_url_reachable: true,
|
||||||
|
secret_status: 'configured',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(summary.severity).toBe('success')
|
||||||
|
})
|
||||||
|
})
|
||||||
65
frontend/src/utils/oauthConfigTest.ts
Normal file
65
frontend/src/utils/oauthConfigTest.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import type { OAuthProviderTestResponse } from '@/api/oauth'
|
||||||
|
|
||||||
|
export type OAuthConfigTestSeverity = 'success' | 'warning' | 'error'
|
||||||
|
|
||||||
|
export interface OAuthConfigTestSummary {
|
||||||
|
severity: OAuthConfigTestSeverity
|
||||||
|
message: string
|
||||||
|
failures: string[]
|
||||||
|
warnings: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeSecretStatus(status: string | undefined): string | null {
|
||||||
|
const normalized = (status || '').trim().toLowerCase()
|
||||||
|
if (!normalized || normalized === 'likely_valid' || normalized === 'configured') return null
|
||||||
|
if (normalized === 'invalid') return 'Secret 无效'
|
||||||
|
if (normalized === 'unsupported') return 'Secret 不受支持'
|
||||||
|
if (normalized === 'not_provided') return 'Secret 未提供'
|
||||||
|
if (normalized === 'unknown') return 'Secret 未验证'
|
||||||
|
return `Secret: ${status}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeOAuthConfigTest(result: OAuthProviderTestResponse): OAuthConfigTestSummary {
|
||||||
|
const failures: string[] = []
|
||||||
|
const warnings: string[] = []
|
||||||
|
|
||||||
|
if (!result.authorization_url_reachable) {
|
||||||
|
failures.push('Authorization URL 不可达')
|
||||||
|
}
|
||||||
|
if (!result.token_url_reachable) {
|
||||||
|
failures.push('Token URL 不可达')
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretStatus = (result.secret_status || '').trim().toLowerCase()
|
||||||
|
const secretMessage = describeSecretStatus(result.secret_status)
|
||||||
|
if (secretMessage && (secretStatus === 'invalid' || secretStatus === 'unsupported')) {
|
||||||
|
failures.push(secretMessage)
|
||||||
|
} else if (secretMessage) {
|
||||||
|
warnings.push(secretMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
return {
|
||||||
|
severity: 'error',
|
||||||
|
message: `测试失败:${failures.join(',')}`,
|
||||||
|
failures,
|
||||||
|
warnings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warnings.length > 0) {
|
||||||
|
return {
|
||||||
|
severity: 'warning',
|
||||||
|
message: `测试完成,但有未确认项:${warnings.join(',')}`,
|
||||||
|
failures,
|
||||||
|
warnings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
severity: 'success',
|
||||||
|
message: '测试通过',
|
||||||
|
failures,
|
||||||
|
warnings,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,46 +15,93 @@
|
|||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6 flex gap-6">
|
||||||
<!-- Provider 选择 Tab -->
|
<!-- 左侧边栏 -->
|
||||||
<div class="flex flex-wrap gap-2 mb-6">
|
<div class="w-56 shrink-0 flex flex-col gap-2">
|
||||||
<button
|
<button
|
||||||
v-for="t in supportedTypes"
|
class="flex items-center justify-center gap-1.5 w-full px-3 py-2 rounded-lg border border-dashed border-border text-sm text-muted-foreground hover:border-primary/50 hover:text-primary transition-colors"
|
||||||
:key="t.provider_type"
|
@click="handleClickAdd"
|
||||||
class="flex items-center gap-3 px-4 py-2 rounded-lg text-sm font-medium transition-colors border"
|
|
||||||
:class="selectedType === t.provider_type
|
|
||||||
? 'border-primary text-primary'
|
|
||||||
: 'border-border text-muted-foreground hover:border-primary/50 hover:text-foreground'"
|
|
||||||
@click="handleTabClick(t.provider_type)"
|
|
||||||
>
|
>
|
||||||
<div class="flex flex-col items-center leading-none">
|
<Plus class="w-3.5 h-3.5" />
|
||||||
<span>{{ t.display_name }}</span>
|
添加配置
|
||||||
<span class="text-[10px] text-muted-foreground">
|
|
||||||
{{ configs[t.provider_type]
|
|
||||||
? (configs[t.provider_type]?.is_enabled ? '点击禁用' : '点击启用')
|
|
||||||
: '未配置' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="w-2 h-2 rounded-full"
|
|
||||||
:class="configs[t.provider_type]?.is_enabled ? 'bg-green-500' : 'bg-gray-300'"
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="configuredList.length === 0 && !loading"
|
||||||
|
class="text-sm text-muted-foreground px-2 py-4 text-center"
|
||||||
|
>
|
||||||
|
暂无配置
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<!-- 新建临时条目 -->
|
||||||
|
<button
|
||||||
|
v-if="newConfigPending"
|
||||||
|
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors"
|
||||||
|
:class="selectedType === '__new__' ? 'bg-primary/10 text-primary' : 'text-foreground hover:bg-muted'"
|
||||||
|
@click="selectNewConfig()"
|
||||||
|
>
|
||||||
|
<div class="w-7 h-7 rounded-md flex items-center justify-center text-xs font-semibold shrink-0"
|
||||||
|
:class="selectedType === '__new__' ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'"
|
||||||
|
>+</div>
|
||||||
|
<div class="flex-1 min-w-0 text-left">
|
||||||
|
<div class="truncate font-medium text-sm">新配置</div>
|
||||||
|
<div class="text-[10px] text-muted-foreground">未保存</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-for="item in sidebarList"
|
||||||
|
:key="item.provider_type"
|
||||||
|
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors"
|
||||||
|
:class="selectedType === item.provider_type
|
||||||
|
? 'bg-primary/10 text-primary'
|
||||||
|
: 'text-foreground hover:bg-muted'"
|
||||||
|
@click="selectProvider(item.provider_type)"
|
||||||
|
>
|
||||||
|
<!-- Logo / 首字母 -->
|
||||||
|
<div
|
||||||
|
class="w-7 h-7 rounded-md shrink-0 flex items-center justify-center text-xs font-semibold overflow-hidden relative"
|
||||||
|
:class="selectedType === item.provider_type ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'"
|
||||||
|
>
|
||||||
|
{{ item.display_name.charAt(0).toUpperCase() }}
|
||||||
|
<img
|
||||||
|
v-if="item.provider_type === 'linuxdo'"
|
||||||
|
src="https://cdn.linux.do/uploads/default/optimized/3X/9/d/9dd49731091ce8656243f3c2b6e5d5e5a7e3e3e3_2_32x32.png"
|
||||||
|
class="absolute inset-0 w-full h-full object-cover"
|
||||||
|
@error="($event.target as HTMLImageElement).remove()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0 text-left">
|
||||||
|
<div class="truncate font-medium text-sm">{{ item.display_name }}</div>
|
||||||
|
<div class="text-[10px] text-muted-foreground">
|
||||||
|
{{ item.configured ? (item.is_enabled ? '已启用' : '已禁用') : '未配置' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 开关 -->
|
||||||
|
<Switch
|
||||||
|
v-if="item.configured"
|
||||||
|
:model-value="item.is_enabled"
|
||||||
|
:disabled="saving"
|
||||||
|
@click.stop
|
||||||
|
@update:model-value="toggleProviderEnabled(item.provider_type, $event)"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="w-1.5 h-1.5 rounded-full shrink-0 bg-gray-200"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 无 Provider 提示 -->
|
<!-- 右侧内容区 -->
|
||||||
<div
|
<div class="flex-1 min-w-0">
|
||||||
v-if="supportedTypes.length === 0 && !loading"
|
|
||||||
class="text-center py-12 text-muted-foreground"
|
|
||||||
>
|
|
||||||
未发现可用的 OAuth Provider
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 配置表单 -->
|
<!-- 配置表单 -->
|
||||||
<CardSection
|
<CardSection
|
||||||
v-if="selectedType"
|
v-if="selectedType"
|
||||||
:title="selectedTypeMeta?.display_name || selectedType"
|
:title="selectedType === '__new__' ? '新建配置' : (selectedTypeMeta?.display_name || selectedType)"
|
||||||
:description="configs[selectedType]?.is_enabled ? '已启用' : '未配置'"
|
:description="selectedType === '__new__' ? '填写后点击保存' : (configs[selectedType]?.is_enabled ? '已启用' : '已禁用')"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -77,6 +124,32 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
<!-- 新建时的 Display Name -->
|
||||||
|
<div
|
||||||
|
v-if="selectedType === '__new__'"
|
||||||
|
class="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Label class="block text-sm font-medium">显示名称</Label>
|
||||||
|
<Input
|
||||||
|
v-model="form.new_display_name"
|
||||||
|
class="mt-1"
|
||||||
|
placeholder="例如:My OIDC Provider"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="block text-sm font-medium">配置标识</Label>
|
||||||
|
<Input
|
||||||
|
v-model="form.new_provider_type"
|
||||||
|
class="mt-1"
|
||||||
|
placeholder="custom_oidc_work"
|
||||||
|
autocomplete="off"
|
||||||
|
@blur="normalizeNewProviderType"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 凭证配置 -->
|
<!-- 凭证配置 -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -121,6 +194,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- custom_oidc 必填端点 -->
|
||||||
|
<div
|
||||||
|
v-if="isSelectedCustomProvider"
|
||||||
|
class="grid grid-cols-1 md:grid-cols-3 gap-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Label class="block text-sm font-medium">Authorization URL</Label>
|
||||||
|
<Input
|
||||||
|
v-model="form.authorization_url_override"
|
||||||
|
class="mt-1"
|
||||||
|
placeholder="https://example.com/oauth/authorize"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="block text-sm font-medium">Token URL</Label>
|
||||||
|
<Input
|
||||||
|
v-model="form.token_url_override"
|
||||||
|
class="mt-1"
|
||||||
|
placeholder="https://example.com/oauth/token"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="block text-sm font-medium">Userinfo URL</Label>
|
||||||
|
<Input
|
||||||
|
v-model="form.userinfo_url_override"
|
||||||
|
class="mt-1"
|
||||||
|
placeholder="https://example.com/api/user"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 高级选项(折叠) -->
|
<!-- 高级选项(折叠) -->
|
||||||
<details class="group">
|
<details class="group">
|
||||||
<summary class="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
|
<summary class="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
|
||||||
@@ -140,7 +247,11 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<!-- linuxdo 的可选端点覆盖 -->
|
||||||
|
<div
|
||||||
|
v-if="!isSelectedCustomProvider"
|
||||||
|
class="grid grid-cols-1 md:grid-cols-3 gap-4"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<Label class="block text-sm font-medium">Authorization URL</Label>
|
<Label class="block text-sm font-medium">Authorization URL</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -181,13 +292,21 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label class="block text-sm font-medium">Extra Config</Label>
|
<Label class="block text-sm font-medium">
|
||||||
|
{{ isSelectedCustomProvider ? 'Allowed Domains / Extra Config' : 'Extra Config' }}
|
||||||
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
v-model="form.extra_config_json"
|
v-model="form.extra_config_json"
|
||||||
class="mt-1 font-mono text-xs"
|
class="mt-1 font-mono text-xs"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="{"min_trust_level": 1}"
|
:placeholder="extraConfigPlaceholder"
|
||||||
/>
|
/>
|
||||||
|
<p
|
||||||
|
v-if="isSelectedCustomProvider"
|
||||||
|
class="mt-1 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
自定义 OIDC 必填;填写 Authorization / Token / Userinfo URL 所属域名。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,36 +352,59 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardSection>
|
</CardSection>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { Plus } from 'lucide-vue-next'
|
||||||
import { oauthApi, type OAuthProviderAdminConfig, type OAuthProviderTestResponse, type SupportedOAuthType } from '@/api/oauth'
|
import { oauthApi, type OAuthProviderAdminConfig, type OAuthProviderTestResponse, type SupportedOAuthType } from '@/api/oauth'
|
||||||
import { PageContainer, PageHeader, CardSection } from '@/components/layout'
|
import { PageContainer, PageHeader, CardSection } from '@/components/layout'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import Input from '@/components/ui/input.vue'
|
import Input from '@/components/ui/input.vue'
|
||||||
import Label from '@/components/ui/label.vue'
|
import Label from '@/components/ui/label.vue'
|
||||||
|
import Switch from '@/components/ui/switch.vue'
|
||||||
import Textarea from '@/components/ui/textarea.vue'
|
import Textarea from '@/components/ui/textarea.vue'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
import { getErrorMessage, getErrorStatus, isApiError } from '@/types/api-error'
|
import { getErrorMessage, getErrorStatus, isApiError } from '@/types/api-error'
|
||||||
|
import { summarizeOAuthConfigTest } from '@/utils/oauthConfigTest'
|
||||||
|
|
||||||
const { success, error: showError } = useToast()
|
const { success, warning, error: showError } = useToast()
|
||||||
const { confirmWarning } = useConfirm()
|
const { confirmWarning } = useConfirm()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const testing = ref(false)
|
const testing = ref(false)
|
||||||
|
|
||||||
|
const BUILTIN_OAUTH_PROVIDER_TYPES = new Set(['linuxdo'])
|
||||||
|
const CUSTOM_OIDC_TEMPLATE_TYPE = 'custom_oidc'
|
||||||
|
|
||||||
|
interface OAuthConfigForm {
|
||||||
|
client_id: string
|
||||||
|
client_secret: string
|
||||||
|
authorization_url_override: string
|
||||||
|
token_url_override: string
|
||||||
|
userinfo_url_override: string
|
||||||
|
scopes_input: string
|
||||||
|
redirect_uri: string
|
||||||
|
frontend_callback_url: string
|
||||||
|
attribute_mapping_json: string
|
||||||
|
extra_config_json: string
|
||||||
|
new_provider_type: string
|
||||||
|
new_display_name: string
|
||||||
|
}
|
||||||
|
|
||||||
const supportedTypes = ref<SupportedOAuthType[]>([])
|
const supportedTypes = ref<SupportedOAuthType[]>([])
|
||||||
const configs = ref<Record<string, OAuthProviderAdminConfig>>({})
|
const configs = ref<Record<string, OAuthProviderAdminConfig>>({})
|
||||||
const selectedType = ref<string>('')
|
const selectedType = ref<string>('')
|
||||||
const lastTestResult = ref<OAuthProviderTestResponse | null>(null)
|
const lastTestResult = ref<OAuthProviderTestResponse | null>(null)
|
||||||
|
const newConfigPending = ref(false)
|
||||||
|
|
||||||
const form = ref({
|
const form = ref<OAuthConfigForm>({
|
||||||
client_id: '',
|
client_id: '',
|
||||||
client_secret: '',
|
client_secret: '',
|
||||||
authorization_url_override: '',
|
authorization_url_override: '',
|
||||||
@@ -273,10 +415,178 @@ const form = ref({
|
|||||||
frontend_callback_url: '',
|
frontend_callback_url: '',
|
||||||
attribute_mapping_json: '',
|
attribute_mapping_json: '',
|
||||||
extra_config_json: '',
|
extra_config_json: '',
|
||||||
|
new_provider_type: '',
|
||||||
|
new_display_name: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const newConfigForm = ref<OAuthConfigForm | null>(null)
|
||||||
|
|
||||||
|
const configuredList = computed(() => Object.values(configs.value))
|
||||||
|
|
||||||
|
const customOidcTemplate = computed(() =>
|
||||||
|
supportedTypes.value.find((type) => type.provider_type === CUSTOM_OIDC_TEMPLATE_TYPE)
|
||||||
|
)
|
||||||
|
|
||||||
|
function isBuiltinProviderType(providerType: string): boolean {
|
||||||
|
return BUILTIN_OAUTH_PROVIDER_TYPES.has(providerType)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCustomProviderType(providerType: string): boolean {
|
||||||
|
return !!providerType && !isBuiltinProviderType(providerType)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarList = computed(() => {
|
||||||
|
const builtins = supportedTypes.value
|
||||||
|
.filter((t) => isBuiltinProviderType(t.provider_type))
|
||||||
|
.map((t) => ({
|
||||||
|
...t,
|
||||||
|
...(configs.value[t.provider_type] || {}),
|
||||||
|
configured: !!configs.value[t.provider_type],
|
||||||
|
is_enabled: configs.value[t.provider_type]?.is_enabled ?? false,
|
||||||
|
}))
|
||||||
|
const customTemplate = customOidcTemplate.value
|
||||||
|
const customs = Object.values(configs.value)
|
||||||
|
.filter((config) => isCustomProviderType(config.provider_type))
|
||||||
|
.map((config) => ({
|
||||||
|
...(customTemplate || {
|
||||||
|
provider_type: config.provider_type,
|
||||||
|
display_name: config.display_name,
|
||||||
|
default_authorization_url: '',
|
||||||
|
default_token_url: '',
|
||||||
|
default_userinfo_url: '',
|
||||||
|
default_scopes: ['openid', 'profile', 'email'],
|
||||||
|
}),
|
||||||
|
...config,
|
||||||
|
configured: true,
|
||||||
|
is_enabled: config.is_enabled,
|
||||||
|
}))
|
||||||
|
return [...builtins, ...customs]
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasSecret = computed(() => !!configs.value[selectedType.value]?.has_secret)
|
const hasSecret = computed(() => !!configs.value[selectedType.value]?.has_secret)
|
||||||
const selectedTypeMeta = computed(() => supportedTypes.value.find((t) => t.provider_type === selectedType.value))
|
const selectedTypeMeta = computed(() => {
|
||||||
|
if (selectedType.value === '__new__') {
|
||||||
|
return customOidcTemplate.value
|
||||||
|
}
|
||||||
|
const builtin = supportedTypes.value.find((t) => t.provider_type === selectedType.value)
|
||||||
|
if (builtin) {
|
||||||
|
return builtin
|
||||||
|
}
|
||||||
|
const config = configs.value[selectedType.value]
|
||||||
|
if (!config) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...(customOidcTemplate.value || {
|
||||||
|
default_authorization_url: '',
|
||||||
|
default_token_url: '',
|
||||||
|
default_userinfo_url: '',
|
||||||
|
default_scopes: ['openid', 'profile', 'email'],
|
||||||
|
}),
|
||||||
|
provider_type: config.provider_type,
|
||||||
|
display_name: config.display_name,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const isSelectedCustomProvider = computed(() =>
|
||||||
|
selectedType.value === '__new__' || isCustomProviderType(selectedType.value)
|
||||||
|
)
|
||||||
|
const extraConfigPlaceholder = computed(() =>
|
||||||
|
isSelectedCustomProvider.value
|
||||||
|
? '{\n "allowed_domains": ["example.com"]\n}'
|
||||||
|
: '{}'
|
||||||
|
)
|
||||||
|
|
||||||
|
function selectProvider(providerType: string) {
|
||||||
|
if (selectedType.value !== providerType) {
|
||||||
|
if (selectedType.value === '__new__') {
|
||||||
|
newConfigForm.value = { ...form.value }
|
||||||
|
}
|
||||||
|
selectedType.value = providerType
|
||||||
|
syncFormFromSelected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectNewConfig() {
|
||||||
|
if (selectedType.value !== '__new__') {
|
||||||
|
selectedType.value = '__new__'
|
||||||
|
if (newConfigForm.value) {
|
||||||
|
form.value = { ...newConfigForm.value }
|
||||||
|
}
|
||||||
|
lastTestResult.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProviderType(value: string): string {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9_-]+/g, '_')
|
||||||
|
.replace(/_+/g, '_')
|
||||||
|
.replace(/^[-_]+|[-_]+$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedNewCustomProviderType(providerType: string): boolean {
|
||||||
|
return providerType === CUSTOM_OIDC_TEMPLATE_TYPE
|
||||||
|
|| providerType.startsWith('custom_oidc_')
|
||||||
|
|| providerType.startsWith('custom_')
|
||||||
|
|| providerType.startsWith('oidc_')
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateUniqueCustomProviderType(base = CUSTOM_OIDC_TEMPLATE_TYPE): string {
|
||||||
|
const rawBase = normalizeProviderType(base) || CUSTOM_OIDC_TEMPLATE_TYPE
|
||||||
|
const normalizedBase = isAllowedNewCustomProviderType(rawBase)
|
||||||
|
? rawBase
|
||||||
|
: `custom_${rawBase}`
|
||||||
|
const used = new Set(Object.keys(configs.value))
|
||||||
|
if (!used.has(normalizedBase)) {
|
||||||
|
return normalizedBase
|
||||||
|
}
|
||||||
|
let index = 2
|
||||||
|
while (used.has(`${normalizedBase}_${index}`)) {
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
return `${normalizedBase}_${index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureNewProviderType(): string {
|
||||||
|
const normalized = normalizeProviderType(form.value.new_provider_type)
|
||||||
|
const providerType = normalized && isAllowedNewCustomProviderType(normalized) && !configs.value[normalized]
|
||||||
|
? normalized
|
||||||
|
: generateUniqueCustomProviderType(normalized || CUSTOM_OIDC_TEMPLATE_TYPE)
|
||||||
|
form.value.new_provider_type = providerType
|
||||||
|
return providerType
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNewProviderType() {
|
||||||
|
const providerType = ensureNewProviderType()
|
||||||
|
const redirectUri = form.value.redirect_uri.trim()
|
||||||
|
if (!redirectUri || /\/api\/oauth\/[^/]+\/callback\/?$/.test(redirectUri)) {
|
||||||
|
form.value.redirect_uri = defaultRedirectUri(providerType)
|
||||||
|
}
|
||||||
|
newConfigForm.value = { ...form.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickAdd() {
|
||||||
|
const providerType = generateUniqueCustomProviderType()
|
||||||
|
selectedType.value = '__new__'
|
||||||
|
newConfigPending.value = true
|
||||||
|
form.value = {
|
||||||
|
client_id: '',
|
||||||
|
client_secret: '',
|
||||||
|
authorization_url_override: '',
|
||||||
|
token_url_override: '',
|
||||||
|
userinfo_url_override: '',
|
||||||
|
scopes_input: 'openid profile email',
|
||||||
|
redirect_uri: defaultRedirectUri(providerType),
|
||||||
|
frontend_callback_url: defaultFrontendCallbackUrl(),
|
||||||
|
attribute_mapping_json: '',
|
||||||
|
extra_config_json: '',
|
||||||
|
new_provider_type: providerType,
|
||||||
|
new_display_name: '',
|
||||||
|
}
|
||||||
|
newConfigForm.value = { ...form.value }
|
||||||
|
lastTestResult.value = null
|
||||||
|
}
|
||||||
|
|
||||||
function defaultRedirectUri(providerType: string): string {
|
function defaultRedirectUri(providerType: string): string {
|
||||||
return new URL(`/api/oauth/${providerType}/callback`, window.location.origin).toString()
|
return new URL(`/api/oauth/${providerType}/callback`, window.location.origin).toString()
|
||||||
@@ -299,16 +609,6 @@ function parseJsonOrNull(input: string): Record<string, unknown> | null {
|
|||||||
return JSON.parse(raw)
|
return JSON.parse(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTabClick(providerType: string) {
|
|
||||||
// 如果点击的是当前选中的 Provider,且已配置,则切换启用状态
|
|
||||||
if (selectedType.value === providerType && configs.value[providerType]) {
|
|
||||||
toggleProviderEnabled(providerType, !configs.value[providerType].is_enabled)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 否则切换到该 Provider
|
|
||||||
selectedType.value = providerType
|
|
||||||
syncFormFromSelected()
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncFormFromSelected() {
|
function syncFormFromSelected() {
|
||||||
lastTestResult.value = null
|
lastTestResult.value = null
|
||||||
@@ -325,6 +625,8 @@ function syncFormFromSelected() {
|
|||||||
frontend_callback_url: cfg?.frontend_callback_url || defaultFrontendCallbackUrl(),
|
frontend_callback_url: cfg?.frontend_callback_url || defaultFrontendCallbackUrl(),
|
||||||
attribute_mapping_json: cfg?.attribute_mapping ? JSON.stringify(cfg.attribute_mapping, null, 2) : '',
|
attribute_mapping_json: cfg?.attribute_mapping ? JSON.stringify(cfg.attribute_mapping, null, 2) : '',
|
||||||
extra_config_json: cfg?.extra_config ? JSON.stringify(cfg.extra_config, null, 2) : '',
|
extra_config_json: cfg?.extra_config ? JSON.stringify(cfg.extra_config, null, 2) : '',
|
||||||
|
new_provider_type: '',
|
||||||
|
new_display_name: '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,8 +642,14 @@ async function toggleProviderEnabled(providerType: string, enabled: boolean, for
|
|||||||
const payload = {
|
const payload = {
|
||||||
display_name: cfg.display_name,
|
display_name: cfg.display_name,
|
||||||
client_id: cfg.client_id,
|
client_id: cfg.client_id,
|
||||||
|
authorization_url_override: cfg.authorization_url_override || null,
|
||||||
|
token_url_override: cfg.token_url_override || null,
|
||||||
|
userinfo_url_override: cfg.userinfo_url_override || null,
|
||||||
|
scopes: cfg.scopes || null,
|
||||||
redirect_uri: cfg.redirect_uri,
|
redirect_uri: cfg.redirect_uri,
|
||||||
frontend_callback_url: cfg.frontend_callback_url,
|
frontend_callback_url: cfg.frontend_callback_url,
|
||||||
|
attribute_mapping: cfg.attribute_mapping || null,
|
||||||
|
extra_config: cfg.extra_config || null,
|
||||||
is_enabled: enabled,
|
is_enabled: enabled,
|
||||||
force,
|
force,
|
||||||
}
|
}
|
||||||
@@ -379,13 +687,14 @@ async function loadAll() {
|
|||||||
])
|
])
|
||||||
supportedTypes.value = types
|
supportedTypes.value = types
|
||||||
configs.value = Object.fromEntries(list.map((c) => [c.provider_type, c]))
|
configs.value = Object.fromEntries(list.map((c) => [c.provider_type, c]))
|
||||||
|
newConfigPending.value = false
|
||||||
|
|
||||||
if (!selectedType.value && supportedTypes.value.length > 0) {
|
if (!selectedType.value || selectedType.value === '__new__') {
|
||||||
selectedType.value = supportedTypes.value[0].provider_type
|
const first = list[0]?.provider_type || types[0]?.provider_type
|
||||||
}
|
if (first) {
|
||||||
|
selectedType.value = first
|
||||||
if (selectedType.value) {
|
syncFormFromSelected()
|
||||||
syncFormFromSelected()
|
}
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
log.error('加载 OAuth 配置失败:', err)
|
log.error('加载 OAuth 配置失败:', err)
|
||||||
@@ -400,10 +709,11 @@ async function handleSave() {
|
|||||||
saving.value = true
|
saving.value = true
|
||||||
lastTestResult.value = null
|
lastTestResult.value = null
|
||||||
try {
|
try {
|
||||||
const typeMeta = supportedTypes.value.find((t) => t.provider_type === selectedType.value)
|
const isNew = selectedType.value === '__new__'
|
||||||
const existingConfig = configs.value[selectedType.value]
|
const providerType = isNew ? ensureNewProviderType() : selectedType.value
|
||||||
|
const existingConfig = configs.value[providerType]
|
||||||
const payload = {
|
const payload = {
|
||||||
display_name: typeMeta?.display_name || selectedType.value,
|
display_name: isNew ? (form.value.new_display_name.trim() || 'Custom OIDC') : (configs.value[providerType]?.display_name || supportedTypes.value.find((t) => t.provider_type === providerType)?.display_name || providerType),
|
||||||
client_id: form.value.client_id.trim(),
|
client_id: form.value.client_id.trim(),
|
||||||
client_secret: form.value.client_secret.trim() || undefined,
|
client_secret: form.value.client_secret.trim() || undefined,
|
||||||
authorization_url_override: form.value.authorization_url_override.trim() || null,
|
authorization_url_override: form.value.authorization_url_override.trim() || null,
|
||||||
@@ -417,9 +727,12 @@ async function handleSave() {
|
|||||||
is_enabled: existingConfig?.is_enabled || false,
|
is_enabled: existingConfig?.is_enabled || false,
|
||||||
}
|
}
|
||||||
|
|
||||||
await oauthApi.admin.upsertProviderConfig(selectedType.value, payload)
|
await oauthApi.admin.upsertProviderConfig(providerType, payload)
|
||||||
success('保存成功')
|
success('保存成功')
|
||||||
|
const savedType = providerType
|
||||||
await loadAll()
|
await loadAll()
|
||||||
|
selectedType.value = savedType
|
||||||
|
syncFormFromSelected()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
showError(getErrorMessage(err, '保存失败'))
|
showError(getErrorMessage(err, '保存失败'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -432,6 +745,7 @@ async function handleTest() {
|
|||||||
if (!selectedType.value) return
|
if (!selectedType.value) return
|
||||||
testing.value = true
|
testing.value = true
|
||||||
try {
|
try {
|
||||||
|
const providerType = selectedType.value === '__new__' ? ensureNewProviderType() : selectedType.value
|
||||||
const testPayload = {
|
const testPayload = {
|
||||||
client_id: form.value.client_id.trim(),
|
client_id: form.value.client_id.trim(),
|
||||||
client_secret: form.value.client_secret.trim() || undefined,
|
client_secret: form.value.client_secret.trim() || undefined,
|
||||||
@@ -439,8 +753,16 @@ async function handleTest() {
|
|||||||
token_url_override: form.value.token_url_override.trim() || null,
|
token_url_override: form.value.token_url_override.trim() || null,
|
||||||
redirect_uri: form.value.redirect_uri.trim(),
|
redirect_uri: form.value.redirect_uri.trim(),
|
||||||
}
|
}
|
||||||
lastTestResult.value = await oauthApi.admin.testProviderConfig(selectedType.value, testPayload)
|
const result = await oauthApi.admin.testProviderConfig(providerType, testPayload)
|
||||||
success('测试完成')
|
lastTestResult.value = result
|
||||||
|
const summary = summarizeOAuthConfigTest(result)
|
||||||
|
if (summary.severity === 'success') {
|
||||||
|
success(summary.message)
|
||||||
|
} else if (summary.severity === 'warning') {
|
||||||
|
warning(summary.message)
|
||||||
|
} else {
|
||||||
|
showError(summary.message)
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
showError(getErrorMessage(err, '测试失败'))
|
showError(getErrorMessage(err, '测试失败'))
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user