feat: improve OAuth provider configuration

This commit is contained in:
fawney19
2026-05-04 00:18:05 +08:00
parent cb5b8ee1ee
commit fb642f7d62
9 changed files with 934 additions and 122 deletions

View File

@@ -41,5 +41,9 @@ fn git_describe_version() -> Option<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()
}

View File

@@ -87,11 +87,36 @@ pub(crate) fn admin_oauth_test_provider_type_from_path(request_path: &str) -> Op
.map(ToOwned::to_owned)
}
fn admin_oauth_is_supported_provider(provider_type: &str) -> bool {
matches!(
provider_type.to_ascii_lowercase().as_str(),
"linuxdo" | "custom_oidc"
)
pub(super) fn admin_oauth_normalized_provider_type(provider_type: &str) -> Option<String> {
let normalized = provider_type.trim().to_ascii_lowercase();
if !(3..=64).contains(&normalized.len()) {
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]> {
@@ -184,7 +209,10 @@ pub(super) fn build_admin_oauth_upsert_record(
provider_type: &str,
payload: AdminOAuthProviderUpsertRequest,
) -> 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());
}
@@ -208,19 +236,19 @@ pub(super) fn build_admin_oauth_upsert_record(
validate_admin_oauth_frontend_callback_url(frontend_callback_url)?;
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 domains = admin_oauth_custom_allowed_domains(payload.extra_config.as_ref());
if domains.is_empty() {
return Err(
"custom_oidc 必须在 extra_config.allowed_domains 配置域名白名单".to_string(),
);
return Err(format!(
"{provider_type} 必须在 extra_config.allowed_domains 配置域名白名单"
));
}
domains
} else {
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 {
for (field_name, value) in [
@@ -309,7 +337,7 @@ pub(super) fn build_admin_oauth_upsert_record(
};
Ok(UpsertOAuthProviderConfigRecord {
provider_type: provider_type.to_string(),
provider_type,
display_name: display_name.to_string(),
client_id: client_id.to_string(),
client_secret_encrypted,

View File

@@ -1,7 +1,8 @@
use super::oauth_config::{
admin_oauth_provider_type_from_path, admin_oauth_test_provider_type_from_path,
build_admin_oauth_provider_payload, build_admin_oauth_supported_types_payload,
build_admin_oauth_upsert_record, AdminOAuthProviderUpsertRequest,
admin_oauth_is_supported_provider, admin_oauth_provider_type_from_path,
admin_oauth_test_provider_type_from_path, build_admin_oauth_provider_payload,
build_admin_oauth_supported_types_payload, build_admin_oauth_upsert_record,
AdminOAuthProviderUpsertRequest,
};
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::{attach_admin_audit_response, build_proxy_error_response};
@@ -13,6 +14,143 @@ use axum::{
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(
state: &AdminAppState<'_>,
@@ -259,16 +397,8 @@ pub(crate) async fn maybe_build_local_admin_oauth_response(
));
}
};
let client_id = payload
.get("client_id")
.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());
let client_id = admin_oauth_payload_string(&payload, "client_id");
let redirect_uri = admin_oauth_payload_string(&payload, "redirect_uri");
if client_id.is_none() || redirect_uri.is_none() {
return Ok(Some(
(
@@ -278,38 +408,9 @@ pub(crate) async fn maybe_build_local_admin_oauth_response(
.into_response(),
));
}
let provided_secret = payload
.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 未安装/不可用"
};
let test_payload = build_admin_oauth_test_payload(state, &provider_type, &payload).await?;
return Ok(Some(attach_admin_audit_response(
Json(json!({
"authorization_url_reachable": false,
"token_url_reachable": false,
"secret_status": secret_status,
"details": details,
}))
.into_response(),
Json(test_payload).into_response(),
"admin_oauth_provider_tested",
"test_oauth_provider_config",
"oauth_provider",

View File

@@ -6197,6 +6197,232 @@ async fn gateway_upserts_custom_oidc_with_allowed_domains() {
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]
async fn gateway_deletes_admin_oauth_provider_locally_with_trusted_admin_principal() {
let upstream_hits = Arc::new(Mutex::new(0usize));