Add shared OAuth flows

This commit is contained in:
fawney19
2026-04-28 15:46:21 +08:00
parent 712b484bc8
commit 70f747d406
56 changed files with 5927 additions and 977 deletions

View File

@@ -17,6 +17,7 @@ aether-data.workspace = true
aether-data-contracts.workspace = true
aether-http.workspace = true
aether-model-fetch.workspace = true
aether-oauth.workspace = true
aether-provider-transport.workspace = true
aether-scheduler-core.workspace = true
aether-runtime.workspace = true

View File

@@ -1,10 +1,14 @@
pub(crate) use crate::handlers::admin::{
admin_provider_ops_local_action_response, admin_provider_pool_config,
build_internal_control_error_response, maybe_build_local_admin_pool_response,
build_internal_control_error_response, create_provider_oauth_catalog_key,
find_duplicate_provider_oauth_key, maybe_build_local_admin_pool_response,
maybe_build_local_admin_response, provider_oauth_runtime_endpoint_for_provider,
refresh_antigravity_provider_quota_locally, refresh_codex_provider_quota_locally,
refresh_kiro_provider_quota_locally, AdminAppState, AdminRequestContext, AdminRouteRequest,
AdminRouteResponse, AdminRouteResult, AdminStatsTimeRange, AdminStatsUsageFilter,
refresh_kiro_provider_quota_locally, refresh_provider_oauth_account_state_after_update,
update_existing_provider_oauth_catalog_key, AdminAppState,
AdminGatewayProviderTransportSnapshot, AdminLocalOAuthRefreshError, AdminRequestContext,
AdminRouteRequest, AdminRouteResponse, AdminRouteResult, AdminStatsTimeRange,
AdminStatsUsageFilter,
};
use crate::handlers::admin::{

View File

@@ -4,7 +4,87 @@ pub(super) fn classify_oauth_route(
method: &http::Method,
normalized_path: &str,
) -> Option<ClassifiedRoute> {
if method == http::Method::GET && normalized_path == "/api/admin/oauth/supported-types" {
if method == http::Method::GET && normalized_path == "/api/oauth/providers" {
Some(classified(
"public_support",
"oauth",
"list_providers",
"user:oauth",
false,
))
} else if method == http::Method::GET
&& normalized_path.starts_with("/api/oauth/")
&& normalized_path.ends_with("/authorize")
{
Some(classified(
"public_support",
"oauth",
"authorize",
"user:oauth",
false,
))
} else if method == http::Method::GET
&& normalized_path.starts_with("/api/oauth/")
&& normalized_path.ends_with("/callback")
{
Some(classified(
"public_support",
"oauth",
"callback",
"user:oauth",
false,
))
} else if method == http::Method::GET && normalized_path == "/api/user/oauth/bindable-providers"
{
Some(classified(
"public_support",
"oauth",
"bindable_providers",
"user:oauth",
false,
))
} else if method == http::Method::GET && normalized_path == "/api/user/oauth/links" {
Some(classified(
"public_support",
"oauth",
"links",
"user:oauth",
false,
))
} else if method == http::Method::POST
&& normalized_path.starts_with("/api/user/oauth/")
&& normalized_path.ends_with("/bind-token")
{
Some(classified(
"public_support",
"oauth",
"bind_token",
"user:oauth",
false,
))
} else if method == http::Method::GET
&& normalized_path.starts_with("/api/user/oauth/")
&& normalized_path.ends_with("/bind")
{
Some(classified(
"public_support",
"oauth",
"bind",
"user:oauth",
false,
))
} else if method == http::Method::DELETE
&& normalized_path.starts_with("/api/user/oauth/")
&& !normalized_path.contains("/bind")
{
Some(classified(
"public_support",
"oauth",
"unbind",
"user:oauth",
false,
))
} else if method == http::Method::GET && normalized_path == "/api/admin/oauth/supported-types" {
Some(classified(
"admin_proxy",
"oauth_manage",

View File

@@ -659,45 +659,73 @@ fn classifies_auth_routes_as_public_support_route() {
}
#[test]
fn does_not_classify_oauth_public_providers_route() {
fn classifies_oauth_public_providers_route() {
let headers = headers(&[]);
let uri: Uri = "/api/oauth/providers".parse().expect("uri should parse");
let decision = classify_control_route(&http::Method::GET, &uri, &headers);
let decision =
classify_control_route(&http::Method::GET, &uri, &headers).expect("route should classify");
assert!(decision.is_none());
assert_eq!(decision.route_class.as_deref(), Some("public_support"));
assert_eq!(decision.route_family.as_deref(), Some("oauth"));
assert_eq!(decision.route_kind.as_deref(), Some("list_providers"));
assert_eq!(
decision.auth_endpoint_signature.as_deref(),
Some("user:oauth")
);
}
#[test]
fn does_not_classify_oauth_public_authorize_route() {
fn classifies_oauth_public_authorize_route() {
let headers = headers(&[]);
let uri: Uri = "/api/oauth/linuxdo/authorize?client_device_id=device-1"
.parse()
.expect("uri should parse");
let decision = classify_control_route(&http::Method::GET, &uri, &headers);
let decision =
classify_control_route(&http::Method::GET, &uri, &headers).expect("route should classify");
assert!(decision.is_none());
assert_eq!(decision.route_class.as_deref(), Some("public_support"));
assert_eq!(decision.route_family.as_deref(), Some("oauth"));
assert_eq!(decision.route_kind.as_deref(), Some("authorize"));
assert_eq!(
decision.auth_endpoint_signature.as_deref(),
Some("user:oauth")
);
}
#[test]
fn does_not_classify_oauth_user_bindable_providers_route() {
fn classifies_oauth_user_bindable_providers_route() {
let headers = headers(&[]);
let uri: Uri = "/api/user/oauth/bindable-providers"
.parse()
.expect("uri should parse");
let decision = classify_control_route(&http::Method::GET, &uri, &headers);
let decision =
classify_control_route(&http::Method::GET, &uri, &headers).expect("route should classify");
assert!(decision.is_none());
assert_eq!(decision.route_class.as_deref(), Some("public_support"));
assert_eq!(decision.route_family.as_deref(), Some("oauth"));
assert_eq!(decision.route_kind.as_deref(), Some("bindable_providers"));
assert_eq!(
decision.auth_endpoint_signature.as_deref(),
Some("user:oauth")
);
}
#[test]
fn does_not_classify_oauth_user_bind_token_route() {
fn classifies_oauth_user_bind_token_route() {
let headers = headers(&[]);
let uri: Uri = "/api/user/oauth/linuxdo/bind-token"
.parse()
.expect("uri should parse");
let decision = classify_control_route(&http::Method::POST, &uri, &headers);
let decision =
classify_control_route(&http::Method::POST, &uri, &headers).expect("route should classify");
assert!(decision.is_none());
assert_eq!(decision.route_class.as_deref(), Some("public_support"));
assert_eq!(decision.route_family.as_deref(), Some("oauth"));
assert_eq!(decision.route_kind.as_deref(), Some("bind_token"));
assert_eq!(
decision.auth_endpoint_signature.as_deref(),
Some("user:oauth")
);
}
#[test]

View File

@@ -34,14 +34,24 @@ pub(crate) struct AdminOAuthProviderUpsertRequest {
}
pub(super) fn build_admin_oauth_supported_types_payload() -> Vec<serde_json::Value> {
vec![json!({
"provider_type": "linuxdo",
"display_name": "Linux Do",
"default_authorization_url": "https://connect.linux.do/oauth2/authorize",
"default_token_url": "https://connect.linux.do/oauth2/token",
"default_userinfo_url": "https://connect.linux.do/api/user",
"default_scopes": [],
})]
vec![
json!({
"provider_type": "linuxdo",
"display_name": "Linux Do",
"default_authorization_url": "https://connect.linux.do/oauth2/authorize",
"default_token_url": "https://connect.linux.do/oauth2/token",
"default_userinfo_url": "https://connect.linux.do/api/user",
"default_scopes": [],
}),
json!({
"provider_type": "custom_oidc",
"display_name": "Custom OIDC",
"default_authorization_url": "",
"default_token_url": "",
"default_userinfo_url": "",
"default_scopes": ["openid", "profile", "email"],
}),
]
}
pub(super) fn build_admin_oauth_provider_payload(
@@ -78,10 +88,13 @@ pub(crate) fn admin_oauth_test_provider_type_from_path(request_path: &str) -> Op
}
fn admin_oauth_is_supported_provider(provider_type: &str) -> bool {
provider_type.eq_ignore_ascii_case("linuxdo")
matches!(
provider_type.to_ascii_lowercase().as_str(),
"linuxdo" | "custom_oidc"
)
}
fn admin_oauth_allowed_domains(provider_type: &str) -> Option<&'static [&'static str]> {
fn admin_oauth_builtin_allowed_domains(provider_type: &str) -> Option<&'static [&'static str]> {
if provider_type.eq_ignore_ascii_case("linuxdo") {
Some(&["linux.do", "connect.linux.do", "connect.linuxdo.org"])
} else {
@@ -89,6 +102,27 @@ fn admin_oauth_allowed_domains(provider_type: &str) -> Option<&'static [&'static
}
}
fn admin_oauth_custom_allowed_domains(extra_config: Option<&serde_json::Value>) -> Vec<String> {
extra_config
.and_then(serde_json::Value::as_object)
.and_then(|object| {
object
.get("allowed_domains")
.or_else(|| object.get("oauth_allowed_domains"))
})
.and_then(serde_json::Value::as_array)
.map(|items| {
items
.iter()
.filter_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.trim_end_matches('.').to_ascii_lowercase())
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn validate_admin_oauth_frontend_callback_url(url: &str) -> Result<(), String> {
let parsed = Url::parse(url).map_err(|_| "frontend_callback_url 必须是绝对 URL".to_string())?;
if !matches!(parsed.scheme(), "http" | "https") {
@@ -134,6 +168,17 @@ fn validate_admin_oauth_url_override(url: &str, allowed_domains: &[&str]) -> Res
Ok(())
}
fn validate_admin_oauth_url_override_for_domains(
url: &str,
allowed_domains: &[String],
) -> Result<(), String> {
let allowed = allowed_domains
.iter()
.map(String::as_str)
.collect::<Vec<_>>();
validate_admin_oauth_url_override(url, &allowed)
}
pub(super) fn build_admin_oauth_upsert_record(
state: &AdminAppState<'_>,
provider_type: &str,
@@ -163,21 +208,64 @@ 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 allowed_domains = admin_oauth_allowed_domains(provider_type)
.ok_or_else(|| "不支持的 provider_type".to_string())?;
let is_custom_oidc = provider_type.eq_ignore_ascii_case("custom_oidc");
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(),
);
}
domains
} else {
Vec::new()
};
let builtin_allowed_domains = admin_oauth_builtin_allowed_domains(provider_type);
if is_custom_oidc {
for (field_name, value) in [
(
"authorization_url_override",
payload.authorization_url_override.as_deref(),
),
("token_url_override", payload.token_url_override.as_deref()),
(
"userinfo_url_override",
payload.userinfo_url_override.as_deref(),
),
] {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Err(format!("custom_oidc 必须配置 {field_name}"));
};
validate_admin_oauth_url_override_for_domains(value, &custom_allowed_domains)?;
}
}
if let Some(value) = payload.authorization_url_override.as_deref().map(str::trim) {
if !value.is_empty() {
validate_admin_oauth_url_override(value, allowed_domains)?;
if let Some(allowed_domains) = builtin_allowed_domains {
validate_admin_oauth_url_override(value, allowed_domains)?;
} else {
validate_admin_oauth_url_override_for_domains(value, &custom_allowed_domains)?;
}
}
}
if let Some(value) = payload.token_url_override.as_deref().map(str::trim) {
if !value.is_empty() {
validate_admin_oauth_url_override(value, allowed_domains)?;
if let Some(allowed_domains) = builtin_allowed_domains {
validate_admin_oauth_url_override(value, allowed_domains)?;
} else {
validate_admin_oauth_url_override_for_domains(value, &custom_allowed_domains)?;
}
}
}
if let Some(value) = payload.userinfo_url_override.as_deref().map(str::trim) {
if !value.is_empty() {
validate_admin_oauth_url_override(value, allowed_domains)?;
if let Some(allowed_domains) = builtin_allowed_domains {
validate_admin_oauth_url_override(value, allowed_domains)?;
} else {
validate_admin_oauth_url_override_for_domains(value, &custom_allowed_domains)?;
}
}
}

View File

@@ -20,11 +20,17 @@ pub(crate) use self::observability::{
admin_stats_bad_request_response, maybe_build_local_admin_usage_response, parse_bounded_u32,
round_to, AdminStatsTimeRange, AdminStatsUsageFilter,
};
pub(crate) use self::provider::oauth::duplicates::find_duplicate_provider_oauth_key;
pub(crate) use self::provider::oauth::errors::build_internal_control_error_response;
pub(crate) use self::provider::oauth::provisioning::{
create_provider_oauth_catalog_key, update_existing_provider_oauth_catalog_key,
};
pub(crate) use self::provider::oauth::quota::antigravity::refresh_antigravity_provider_quota_locally;
pub(crate) use self::provider::oauth::quota::codex::refresh_codex_provider_quota_locally;
pub(crate) use self::provider::oauth::quota::kiro::refresh_kiro_provider_quota_locally;
pub(crate) use self::provider::oauth::runtime::provider_oauth_runtime_endpoint_for_provider;
pub(crate) use self::provider::oauth::runtime::{
provider_oauth_runtime_endpoint_for_provider, refresh_provider_oauth_account_state_after_update,
};
pub(crate) use self::provider::ops::providers::actions::admin_provider_ops_local_action_response;
pub(crate) use self::provider::pool::config::admin_provider_pool_config;
pub(crate) use self::provider::pool_admin::maybe_build_local_admin_pool_response;
@@ -32,7 +38,8 @@ pub(crate) use self::provider::{
maybe_build_local_admin_provider_oauth_response, maybe_build_local_admin_providers_response,
};
pub(crate) use self::request::{
AdminAppState, AdminRequestContext, AdminRouteRequest, AdminRouteResponse, AdminRouteResult,
AdminAppState, AdminGatewayProviderTransportSnapshot, AdminLocalOAuthRefreshError,
AdminRequestContext, AdminRouteRequest, AdminRouteResponse, AdminRouteResult,
};
pub(crate) use self::routes::maybe_build_local_admin_response;
#[cfg(test)]

View File

@@ -95,6 +95,19 @@ pub(super) async fn execute_admin_provider_oauth_refresh(
response::oauth_refresh_failed_service_unavailable_response(source.to_string()),
));
}
Err(AdminLocalOAuthRefreshError::TransportMessage { message, .. }) => {
tracing::warn!(
trace_id = %trace_id,
key_id = %key_id,
provider_id = %provider.id,
provider_type = %provider_type,
error = %message,
"gateway manual provider oauth refresh transport failed"
);
return Ok(RefreshDispatch::Respond(
response::oauth_refresh_failed_service_unavailable_response(message),
));
}
Err(AdminLocalOAuthRefreshError::InvalidResponse { message, .. }) => {
tracing::warn!(
trace_id = %trace_id,

View File

@@ -1,11 +1,12 @@
use super::super::errors::{
build_internal_control_error_response, normalize_provider_oauth_refresh_error_message,
};
use super::json_non_empty_string;
use crate::handlers::admin::request::{AdminAppState, AdminProviderOAuthTemplate};
use aether_contracts::ProxySnapshot;
use aether_oauth::provider::providers::GenericProviderOAuthAdapter;
use aether_oauth::provider::{ProviderOAuthService, ProviderOAuthTransportContext};
use axum::{body::Body, http, response::Response};
use url::form_urlencoded;
use std::sync::Arc;
fn provider_oauth_transport_error_detail(prefix: &str, error: &str) -> String {
let error = error.trim();
@@ -15,6 +16,51 @@ fn provider_oauth_transport_error_detail(prefix: &str, error: &str) -> String {
format!("{prefix}: {error}")
}
fn provider_oauth_exchange_context(
provider_type: &str,
proxy: Option<ProxySnapshot>,
) -> ProviderOAuthTransportContext {
ProviderOAuthTransportContext {
provider_id: String::new(),
provider_type: provider_type.to_string(),
endpoint_id: None,
key_id: None,
auth_type: Some("oauth".to_string()),
decrypted_api_key: None,
decrypted_auth_config: None,
provider_config: None,
endpoint_config: None,
key_config: None,
network: aether_oauth::network::OAuthNetworkContext::provider_operation(proxy),
}
}
fn provider_oauth_service_for_template(
template: AdminProviderOAuthTemplate,
token_url: String,
) -> Result<ProviderOAuthService, Response<Body>> {
GenericProviderOAuthAdapter::for_provider_type(template.provider_type)
.map(|adapter| adapter.with_token_url_override(token_url))
.map(|adapter| ProviderOAuthService::new().with_adapter(Arc::new(adapter)))
.ok_or_else(|| {
build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"该 Provider 不支持 OAuth 授权",
)
})
}
fn token_payload_from_provider_oauth_result(
result: aether_oauth::provider::ProviderOAuthTokenSet,
) -> Result<serde_json::Value, Response<Body>> {
result.token_set.raw_payload.ok_or_else(|| {
build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"token exchange 返回缺少 access_token",
)
})
}
pub(crate) async fn exchange_admin_provider_oauth_code(
state: &AdminAppState<'_>,
template: AdminProviderOAuthTemplate,
@@ -24,122 +70,25 @@ pub(crate) async fn exchange_admin_provider_oauth_code(
proxy: Option<ProxySnapshot>,
) -> Result<serde_json::Value, Response<Body>> {
let token_url = state.provider_oauth_token_url(template.provider_type, template.token_url);
let response = if template.provider_type == "claude_code" {
let mut body = serde_json::Map::from_iter([
(
"grant_type".to_string(),
serde_json::Value::String("authorization_code".to_string()),
),
(
"client_id".to_string(),
serde_json::Value::String(template.client_id.to_string()),
),
(
"redirect_uri".to_string(),
serde_json::Value::String(template.redirect_uri.to_string()),
),
(
"code".to_string(),
serde_json::Value::String(code.to_string()),
),
(
"state".to_string(),
serde_json::Value::String(state_nonce.to_string()),
),
]);
if let Some(verifier) = pkce_verifier {
body.insert(
"code_verifier".to_string(),
serde_json::Value::String(verifier.to_string()),
);
}
let headers = reqwest::header::HeaderMap::from_iter([
(
reqwest::header::CONTENT_TYPE,
reqwest::header::HeaderValue::from_static("application/json"),
),
(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static("application/json"),
),
]);
state
.execute_admin_provider_oauth_http_request(
"provider-oauth:exchange-code",
reqwest::Method::POST,
&token_url,
&headers,
Some("application/json"),
Some(serde_json::Value::Object(body)),
None,
proxy.clone(),
)
.await
} else {
let form_body = {
let mut form = form_urlencoded::Serializer::new(String::new());
form.append_pair("grant_type", "authorization_code");
form.append_pair("client_id", template.client_id);
form.append_pair("redirect_uri", template.redirect_uri);
form.append_pair("code", code);
if !template.client_secret.trim().is_empty() {
form.append_pair("client_secret", template.client_secret);
let service = provider_oauth_service_for_template(template, token_url)?;
let ctx = provider_oauth_exchange_context(template.provider_type, proxy);
let executor = crate::oauth::GatewayOAuthHttpExecutor::new(*state);
let result = service
.exchange_code(&executor, &ctx, code, state_nonce, pkce_verifier)
.await
.map_err(|error| match error {
aether_oauth::core::OAuthError::HttpStatus { .. } => {
build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"token exchange 失败",
)
}
if let Some(verifier) = pkce_verifier {
form.append_pair("code_verifier", verifier);
}
form.finish().into_bytes()
};
let headers = reqwest::header::HeaderMap::from_iter([
(
reqwest::header::CONTENT_TYPE,
reqwest::header::HeaderValue::from_static("application/x-www-form-urlencoded"),
error => build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
provider_oauth_transport_error_detail("token exchange 失败", &error.to_string()),
),
(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static("application/json"),
),
]);
state
.execute_admin_provider_oauth_http_request(
"provider-oauth:exchange-code",
reqwest::Method::POST,
&token_url,
&headers,
Some("application/x-www-form-urlencoded"),
None,
Some(form_body),
proxy.clone(),
)
.await
}
.map_err(|error| {
build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
provider_oauth_transport_error_detail("token exchange 失败", &error),
)
})?;
if !response.status.is_success() {
return Err(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"token exchange 失败",
));
}
let payload = response.json_body.ok_or_else(|| {
build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"token exchange 返回缺少 access_token",
)
})?;
if json_non_empty_string(payload.get("access_token")).is_none() {
return Err(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"token exchange 返回缺少 access_token",
));
}
Ok(payload)
})?;
token_payload_from_provider_oauth_result(result)
}
pub(crate) async fn exchange_admin_provider_oauth_refresh_token(
@@ -149,119 +98,45 @@ pub(crate) async fn exchange_admin_provider_oauth_refresh_token(
proxy: Option<ProxySnapshot>,
) -> Result<serde_json::Value, Response<Body>> {
let token_url = state.provider_oauth_token_url(template.provider_type, template.token_url);
let scope = template.scopes.join(" ");
let response = if template.provider_type == "claude_code" {
let mut body = serde_json::Map::from_iter([
(
"grant_type".to_string(),
serde_json::Value::String("refresh_token".to_string()),
),
(
"client_id".to_string(),
serde_json::Value::String(template.client_id.to_string()),
),
(
"refresh_token".to_string(),
serde_json::Value::String(refresh_token.to_string()),
),
]);
if !scope.trim().is_empty() {
body.insert("scope".to_string(), serde_json::Value::String(scope));
}
let headers = reqwest::header::HeaderMap::from_iter([
(
reqwest::header::CONTENT_TYPE,
reqwest::header::HeaderValue::from_static("application/json"),
),
(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static("application/json"),
),
]);
state
.execute_admin_provider_oauth_http_request(
"provider-oauth:refresh-token",
reqwest::Method::POST,
&token_url,
&headers,
Some("application/json"),
Some(serde_json::Value::Object(body)),
None,
proxy.clone(),
)
.await
} else {
let form_body = {
let mut form = form_urlencoded::Serializer::new(String::new());
form.append_pair("grant_type", "refresh_token");
form.append_pair("client_id", template.client_id);
form.append_pair("refresh_token", refresh_token);
if !scope.trim().is_empty() {
form.append_pair("scope", &scope);
let service = provider_oauth_service_for_template(template, token_url)?;
let ctx = provider_oauth_exchange_context(template.provider_type, proxy);
let executor = crate::oauth::GatewayOAuthHttpExecutor::new(*state);
let input = aether_oauth::provider::ProviderOAuthImportInput {
provider_type: template.provider_type.to_string(),
name: None,
refresh_token: Some(refresh_token.to_string()),
raw_credentials: None,
network: ctx.network.clone(),
};
let result = service
.import_credentials(&executor, &ctx, input)
.await
.map_err(|error| match error {
aether_oauth::core::OAuthError::HttpStatus {
status_code,
body_excerpt,
} => {
let reason = normalize_provider_oauth_refresh_error_message(
Some(status_code),
Some(&body_excerpt),
);
build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
format!("Refresh Token 验证失败: {reason}"),
)
}
if !template.client_secret.trim().is_empty() {
form.append_pair("client_secret", template.client_secret);
}
form.finish().into_bytes()
};
let headers = reqwest::header::HeaderMap::from_iter([
(
reqwest::header::CONTENT_TYPE,
reqwest::header::HeaderValue::from_static("application/x-www-form-urlencoded"),
error => build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
provider_oauth_transport_error_detail(
"Refresh Token 验证失败: token exchange 失败",
&error.to_string(),
),
),
(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static("application/json"),
),
]);
state
.execute_admin_provider_oauth_http_request(
"provider-oauth:refresh-token",
reqwest::Method::POST,
&token_url,
&headers,
Some("application/x-www-form-urlencoded"),
None,
Some(form_body),
proxy.clone(),
)
.await
}
.map_err(|error| {
})?;
token_payload_from_provider_oauth_result(result).map_err(|_| {
build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
provider_oauth_transport_error_detail(
"Refresh Token 验证失败: token exchange 失败",
&error,
),
)
})?;
let status = response.status;
let body = response.body_text;
if !status.is_success() {
let reason =
normalize_provider_oauth_refresh_error_message(Some(status.as_u16()), Some(&body));
return Err(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
format!("Refresh Token 验证失败: {reason}"),
));
}
let payload = response
.json_body
.or_else(|| serde_json::from_str::<serde_json::Value>(&body).ok())
.ok_or_else(|| {
build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"token refresh 返回缺少 access_token",
)
})?;
if json_non_empty_string(payload.get("access_token")).is_none() {
return Err(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"token refresh 返回缺少 access_token",
));
}
Ok(payload)
)
})
}

View File

@@ -1,4 +1,5 @@
use crate::handlers::admin::request::AdminProviderOAuthTemplate;
use aether_oauth::provider::{ProviderOAuthService, ProviderOAuthTransportContext};
use serde_json::json;
use url::form_urlencoded;
@@ -7,6 +8,48 @@ pub(crate) fn build_provider_oauth_start_response(
nonce: &str,
code_challenge: Option<&str>,
) -> serde_json::Value {
let authorization_url = build_provider_oauth_authorization_url(template, nonce, code_challenge)
.unwrap_or_else(|| {
build_provider_oauth_authorization_url_legacy(template, nonce, code_challenge)
});
json!({
"authorization_url": authorization_url,
"redirect_uri": template.redirect_uri,
"provider_type": template.provider_type,
"instructions": "1) 打开 authorization_url 完成授权\n2) 授权后会跳转到 redirect_urilocalhost\n3) 复制浏览器地址栏完整 URL调用 complete 接口粘贴 callback_url",
})
}
fn build_provider_oauth_authorization_url(
template: AdminProviderOAuthTemplate,
nonce: &str,
code_challenge: Option<&str>,
) -> Option<String> {
let ctx = ProviderOAuthTransportContext {
provider_id: String::new(),
provider_type: template.provider_type.to_string(),
endpoint_id: None,
key_id: None,
auth_type: Some("oauth".to_string()),
decrypted_api_key: None,
decrypted_auth_config: None,
provider_config: None,
endpoint_config: None,
key_config: None,
network: aether_oauth::network::OAuthNetworkContext::provider_operation(None),
};
ProviderOAuthService::with_builtin_adapters()
.build_authorize_url(&ctx, nonce, code_challenge)
.ok()
.map(|response| response.authorize_url)
}
fn build_provider_oauth_authorization_url_legacy(
template: AdminProviderOAuthTemplate,
nonce: &str,
code_challenge: Option<&str>,
) -> String {
let mut serializer = form_urlencoded::Serializer::new(String::new());
serializer.append_pair("client_id", template.client_id);
serializer.append_pair("response_type", "code");
@@ -25,10 +68,5 @@ pub(crate) fn build_provider_oauth_start_response(
}
}
json!({
"authorization_url": format!("{}?{}", template.authorize_url, serializer.finish()),
"redirect_uri": template.redirect_uri,
"provider_type": template.provider_type,
"instructions": "1) 打开 authorization_url 完成授权\n2) 授权后会跳转到 redirect_urilocalhost\n3) 复制浏览器地址栏完整 URL调用 complete 接口粘贴 callback_url",
})
format!("{}?{}", template.authorize_url, serializer.finish())
}

View File

@@ -37,23 +37,24 @@ impl<'a> AdminAppState<'a> {
encrypted_auth_config: Option<&str>,
expires_at_unix_secs: Option<u64>,
) -> Result<bool, GatewayError> {
self.app
.update_provider_catalog_key_oauth_credentials(
key_id,
encrypted_api_key,
encrypted_auth_config,
expires_at_unix_secs,
)
.await
crate::oauth::ProviderOAuthRepository::update_provider_catalog_key_oauth_credentials(
self,
key_id,
encrypted_api_key,
encrypted_auth_config,
expires_at_unix_secs,
)
.await
}
pub(crate) async fn clear_provider_catalog_key_oauth_invalid_marker(
&self,
key_id: &str,
) -> Result<bool, GatewayError> {
self.app
.clear_provider_catalog_key_oauth_invalid_marker(key_id)
.await
crate::oauth::ProviderOAuthRepository::clear_provider_catalog_key_oauth_invalid_marker(
self, key_id,
)
.await
}
pub(crate) async fn force_local_oauth_refresh_entry(
@@ -61,7 +62,8 @@ impl<'a> AdminAppState<'a> {
transport: &AdminGatewayProviderTransportSnapshot,
) -> Result<Option<crate::provider_transport::CachedOAuthEntry>, AdminLocalOAuthRefreshError>
{
self.app.force_local_oauth_refresh_entry(transport).await
crate::oauth::ProviderOAuthRepository::force_local_oauth_refresh_entry(self, transport)
.await
}
pub(crate) async fn save_provider_oauth_state(
@@ -425,23 +427,12 @@ impl<'a> AdminAppState<'a> {
temporary_proxy_node_id: Option<&str>,
configured_proxies: &[Option<&serde_json::Value>],
) -> Option<ProxySnapshot> {
if let Some(snapshot) = self
.resolve_admin_proxy_node_snapshot(temporary_proxy_node_id)
.await
{
return Some(snapshot);
}
for proxy in configured_proxies {
if let Some(snapshot) = self
.app
.resolve_configured_proxy_snapshot_with_tunnel_affinity(*proxy)
.await
{
return Some(snapshot);
}
}
self.app.resolve_system_proxy_snapshot().await
crate::oauth::resolve_provider_oauth_operation_proxy_snapshot(
self,
temporary_proxy_node_id,
configured_proxies,
)
.await
}
pub(crate) async fn find_duplicate_provider_oauth_key(
@@ -453,7 +444,7 @@ impl<'a> AdminAppState<'a> {
Option<aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey>,
String,
> {
crate::handlers::admin::provider::oauth::duplicates::find_duplicate_provider_oauth_key(
crate::oauth::ProviderOAuthRepository::find_duplicate_provider_oauth_key(
self,
provider_id,
auth_config,
@@ -476,7 +467,7 @@ impl<'a> AdminAppState<'a> {
Option<aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey>,
GatewayError,
> {
crate::handlers::admin::provider::oauth::provisioning::create_provider_oauth_catalog_key(
crate::oauth::ProviderOAuthRepository::create_provider_oauth_catalog_key(
self,
provider_id,
provider_type,
@@ -503,7 +494,7 @@ impl<'a> AdminAppState<'a> {
Option<aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey>,
GatewayError,
> {
crate::handlers::admin::provider::oauth::provisioning::update_existing_provider_oauth_catalog_key(
crate::oauth::ProviderOAuthRepository::update_existing_provider_oauth_catalog_key(
self,
existing_key,
provider_type,
@@ -522,7 +513,7 @@ impl<'a> AdminAppState<'a> {
key_id: &str,
proxy_override: Option<&ProxySnapshot>,
) -> Result<(bool, Option<String>), GatewayError> {
crate::handlers::admin::provider::oauth::runtime::refresh_provider_oauth_account_state_after_update(
crate::oauth::ProviderOAuthRepository::refresh_provider_oauth_account_state_after_update(
self,
provider,
key_id,
@@ -618,56 +609,31 @@ impl<'a> AdminAppState<'a> {
body_bytes: Option<Vec<u8>>,
proxy: Option<ProxySnapshot>,
) -> Result<AdminProviderOAuthHttpResponse, String> {
let body = if let Some(json_body) = json_body {
RequestBody::from_json(json_body)
} else {
RequestBody {
json_body: None,
body_bytes_b64: body_bytes.map(|bytes| STANDARD.encode(bytes)),
body_ref: None,
}
};
let timeout_ms = admin_provider_oauth_timeout_ms(proxy.as_ref());
let plan = ExecutionPlan {
let network = aether_oauth::network::OAuthNetworkContext::provider_operation(proxy);
let request = aether_oauth::network::OAuthHttpRequest {
request_id: request_id.to_string(),
candidate_id: None,
provider_name: Some("provider_oauth".to_string()),
provider_id: String::new(),
endpoint_id: String::new(),
key_id: String::new(),
method: method.as_str().to_string(),
method,
url: url.to_string(),
headers: admin_provider_oauth_execution_headers(headers),
content_type: content_type
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
content_encoding: None,
body,
stream: false,
client_api_format: "provider_oauth:exchange".to_string(),
provider_api_format: "provider_oauth:exchange".to_string(),
model_name: Some("oauth-exchange".to_string()),
proxy,
tls_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(timeout_ms),
read_ms: Some(timeout_ms),
write_ms: Some(timeout_ms),
pool_ms: Some(timeout_ms),
total_ms: Some(timeout_ms),
..ExecutionTimeouts::default()
}),
json_body,
body_bytes,
network,
};
let result = self
.execute_execution_runtime_sync_plan(None, &plan)
.await
.map_err(admin_provider_oauth_gateway_error_message)?;
let response = aether_oauth::network::OAuthHttpExecutor::execute(
&crate::oauth::GatewayOAuthHttpExecutor::new(*self),
request,
)
.await
.map_err(|err| err.to_string())?;
Ok(AdminProviderOAuthHttpResponse {
status: http::StatusCode::from_u16(result.status_code)
status: http::StatusCode::from_u16(response.status_code)
.unwrap_or(http::StatusCode::BAD_GATEWAY),
body_text: admin_provider_oauth_execution_body_text(&result),
json_body: admin_provider_oauth_execution_json_body(&result),
body_text: response.body_text,
json_body: response.json_body,
})
}
}

View File

@@ -32,6 +32,8 @@ mod support_dashboard;
mod support_models;
#[path = "support/monitoring.rs"]
mod support_monitoring;
#[path = "support/oauth.rs"]
mod support_oauth;
#[path = "support/payment.rs"]
mod support_payment;
#[path = "support/test_connection.rs"]
@@ -56,13 +58,14 @@ use self::support_auth::auth_session::{
};
use self::support_auth::{
build_auth_error_response, build_auth_json_response, build_auth_registration_settings_payload,
build_auth_settings_payload, maybe_build_local_auth_response,
build_auth_settings_payload, extract_client_device_id, maybe_build_local_auth_response,
};
use self::support_dashboard::maybe_build_local_dashboard_response;
use self::support_models::{
build_models_auth_error_response, maybe_build_local_models_response, models_api_format,
};
use self::support_monitoring::maybe_build_local_user_monitoring_response;
use self::support_oauth::maybe_build_local_oauth_response;
use self::support_payment::maybe_build_local_payment_callback_response;
use self::support_test_connection::maybe_build_local_test_connection_response;
use self::support_user_me::maybe_build_local_users_me_response;
@@ -106,6 +109,11 @@ pub(crate) async fn maybe_build_local_public_support_response(
.await;
}
if decision.route_family.as_deref() == Some("oauth") {
return maybe_build_local_oauth_response(state, request_context, headers, request_body)
.await;
}
if decision.route_family.as_deref() == Some("dashboard") {
return Some(maybe_build_local_dashboard_response(state, request_context, headers).await);
}

View File

@@ -236,7 +236,7 @@ pub(super) fn extract_cookie_value(headers: &http::HeaderMap, cookie_name: &str)
None
}
pub(super) fn extract_client_device_id(
pub(crate) fn extract_client_device_id(
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
) -> Result<String, Response<Body>> {

View File

@@ -22,7 +22,7 @@ fn base64url_decode(value: &str) -> Result<Vec<u8>, String> {
.map_err(|_| "无效的Token".to_string())
}
pub(super) fn create_auth_token(
pub(crate) fn create_auth_token(
token_type: &str,
mut payload: serde_json::Map<String, serde_json::Value>,
expires_at: chrono::DateTime<chrono::Utc>,
@@ -54,7 +54,7 @@ pub(super) fn create_auth_token(
))
}
pub(super) fn decode_auth_token(
pub(crate) fn decode_auth_token(
token: &str,
expected_type: &str,
) -> Result<serde_json::Map<String, serde_json::Value>, String> {
@@ -525,7 +525,7 @@ pub(super) async fn handle_auth_refresh(
)
}
pub(super) async fn build_auth_login_success_response(
pub(crate) async fn build_auth_login_success_response(
state: &AppState,
headers: &http::HeaderMap,
client_device_id: String,

View File

@@ -0,0 +1,728 @@
use super::support_auth::auth_session::{
build_auth_login_success_response, create_auth_token, decode_auth_token,
};
use super::{
build_auth_error_response, build_auth_json_response, extract_client_device_id, http, json,
query_param_value, resolve_authenticated_local_user, AppState, Body, Bytes,
GatewayPublicRequestContext, IntoResponse, Json, Response,
};
use aether_oauth::core::{generate_pkce_verifier, pkce_s256, OAuthError};
use aether_oauth::identity::{
IdentityClaims, IdentityOAuthExchangeContext, IdentityOAuthService, IdentityOAuthStartContext,
};
use axum::body::to_bytes;
use axum::http::header::{LOCATION, SET_COOKIE};
use axum::http::HeaderValue;
use url::form_urlencoded;
pub(super) async fn maybe_build_local_oauth_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
_request_body: Option<&Bytes>,
) -> Option<Response<Body>> {
let decision = request_context.control_decision.as_ref()?;
if decision.route_family.as_deref() != Some("oauth") {
return None;
}
match decision.route_kind.as_deref() {
Some("list_providers")
if request_context.request_method == http::Method::GET
&& request_context.request_path == "/api/oauth/providers" =>
{
Some(handle_oauth_list_providers(state).await)
}
Some("authorize") if request_context.request_method == http::Method::GET => {
Some(handle_oauth_authorize(state, request_context, headers).await)
}
Some("callback") if request_context.request_method == http::Method::GET => {
Some(handle_oauth_callback(state, request_context, headers).await)
}
Some("bindable_providers")
if request_context.request_method == http::Method::GET
&& request_context.request_path == "/api/user/oauth/bindable-providers" =>
{
Some(handle_oauth_bindable_providers(state, request_context, headers).await)
}
Some("links")
if request_context.request_method == http::Method::GET
&& request_context.request_path == "/api/user/oauth/links" =>
{
Some(handle_oauth_links(state, request_context, headers).await)
}
Some("bind_token") if request_context.request_method == http::Method::POST => {
Some(handle_oauth_bind_token(state, request_context, headers).await)
}
Some("bind") if request_context.request_method == http::Method::GET => {
Some(handle_oauth_bind_start(state, request_context, headers).await)
}
Some("unbind") if request_context.request_method == http::Method::DELETE => {
Some(handle_oauth_unbind(state, request_context, headers).await)
}
_ => Some(super::build_unhandled_public_support_response(
request_context,
)),
}
}
async fn handle_oauth_list_providers(state: &AppState) -> Response<Body> {
match crate::oauth::list_enabled_identity_oauth_providers(state).await {
Ok(providers) => Json(json!({ "providers": providers })).into_response(),
Err(err) => build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("oauth provider lookup failed: {err:?}"),
false,
),
}
}
async fn handle_oauth_bindable_providers(
state: &AppState,
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
) -> Response<Body> {
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
Ok(value) => value,
Err(response) => return response,
};
if auth.user.auth_source.eq_ignore_ascii_case("ldap") {
return Json(json!({ "providers": [] })).into_response();
}
match crate::oauth::list_bindable_identity_oauth_providers(state, &auth.user.id).await {
Ok(providers) => Json(json!({ "providers": providers })).into_response(),
Err(err) => build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("oauth provider lookup failed: {err:?}"),
false,
),
}
}
async fn handle_oauth_links(
state: &AppState,
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
) -> Response<Body> {
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
Ok(value) => value,
Err(response) => return response,
};
match crate::oauth::list_identity_oauth_links(state, &auth.user.id).await {
Ok(links) => Json(json!({ "links": links })).into_response(),
Err(err) => build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("oauth link lookup failed: {err:?}"),
false,
),
}
}
async fn handle_oauth_authorize(
state: &AppState,
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
) -> Response<Body> {
let Some(provider_type) =
public_oauth_provider_from_path(&request_context.request_path, "authorize")
else {
return build_auth_error_response(
http::StatusCode::NOT_FOUND,
"OAuth Provider 不存在",
false,
);
};
let client_device_id = match extract_client_device_id(request_context, headers) {
Ok(value) => value,
Err(response) => return response,
};
start_identity_oauth(
state,
&provider_type,
client_device_id,
crate::oauth::IdentityOAuthStateMode::Login,
None,
None,
)
.await
}
async fn handle_oauth_bind_token(
state: &AppState,
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
) -> Response<Body> {
let Some(provider_type) =
user_oauth_provider_from_path(&request_context.request_path, "bind-token")
else {
return build_auth_error_response(
http::StatusCode::NOT_FOUND,
"OAuth Provider 不存在",
false,
);
};
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
Ok(value) => value,
Err(response) => return response,
};
if auth.user.auth_source.eq_ignore_ascii_case("ldap") {
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"LDAP 用户不支持 OAuth 绑定",
false,
);
}
match crate::oauth::get_enabled_identity_oauth_provider_config(state, &provider_type).await {
Ok(Some(_)) => {}
Ok(None) => {
return build_auth_error_response(
http::StatusCode::NOT_FOUND,
"OAuth Provider 不存在或已禁用",
false,
)
}
Err(err) => return oauth_account_error_response(err),
}
let token = match create_auth_token(
"oauth_bind",
serde_json::Map::from_iter([
("user_id".to_string(), json!(auth.user.id)),
("session_id".to_string(), json!(auth.session_id)),
("provider_type".to_string(), json!(provider_type)),
]),
chrono::Utc::now() + chrono::Duration::minutes(10),
) {
Ok(value) => value,
Err(detail) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
detail,
false,
)
}
};
build_auth_json_response(http::StatusCode::OK, json!({ "bind_token": token }), None)
}
async fn handle_oauth_bind_start(
state: &AppState,
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
) -> Response<Body> {
let Some(provider_type) = user_oauth_provider_from_path(&request_context.request_path, "bind")
else {
return build_auth_error_response(
http::StatusCode::NOT_FOUND,
"OAuth Provider 不存在",
false,
);
};
let client_device_id = match extract_client_device_id(request_context, headers) {
Ok(value) => value,
Err(response) => return response,
};
let Some(bind_token) = query_param_value(
request_context.request_query_string.as_deref(),
"bind_token",
) else {
return build_auth_error_response(http::StatusCode::BAD_REQUEST, "缺少绑定令牌", false);
};
let bind =
match validate_bind_token(state, &provider_type, &client_device_id, &bind_token).await {
Ok(value) => value,
Err(response) => return response,
};
start_identity_oauth(
state,
&provider_type,
client_device_id,
crate::oauth::IdentityOAuthStateMode::Bind,
Some(bind.user_id),
Some(bind.session_id),
)
.await
}
async fn handle_oauth_callback(
state: &AppState,
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
) -> Response<Body> {
let Some(provider_type) =
public_oauth_provider_from_path(&request_context.request_path, "callback")
else {
return redirect_oauth_error(None, "provider_unavailable");
};
let params = callback_params(request_context);
if params
.get("error")
.is_some_and(|value| value.eq_ignore_ascii_case("access_denied"))
{
return redirect_oauth_error(None, "authorization_denied");
}
let Some(code) = params
.get("code")
.map(String::as_str)
.filter(|value| !value.is_empty())
else {
return redirect_oauth_error(None, "invalid_callback");
};
let Some(nonce) = params
.get("state")
.map(String::as_str)
.filter(|value| !value.is_empty())
else {
return redirect_oauth_error(None, "invalid_state");
};
let stored = match crate::oauth::consume_identity_oauth_state(state, nonce).await {
Ok(Some(value)) => value,
Ok(None) => return redirect_oauth_error(None, "invalid_state"),
Err(_) => return redirect_oauth_error(None, "invalid_state"),
};
if stored.provider_type != provider_type {
return redirect_oauth_error(None, "invalid_state");
}
let config =
match crate::oauth::get_enabled_identity_oauth_provider_config(state, &provider_type).await
{
Ok(Some(value)) => value,
Ok(None) => return redirect_oauth_error(None, "provider_disabled"),
Err(err) => return redirect_oauth_error(None, err.code()),
};
let network = crate::oauth::resolve_identity_oauth_network_context(state).await;
let exchange_ctx = IdentityOAuthExchangeContext {
code: code.to_string(),
state: nonce.to_string(),
pkce_verifier: stored.pkce_verifier.clone(),
network,
};
let executor = crate::oauth::GatewayOAuthHttpExecutor::from_app(state);
let service = IdentityOAuthService::with_builtin_providers();
let claims = match service.login(&executor, &config, &exchange_ctx).await {
Ok(outcome) => outcome.claims,
Err(err) => {
return redirect_oauth_error(
Some(&config.frontend_callback_url),
oauth_error_code(&err),
)
}
};
match stored.mode {
crate::oauth::IdentityOAuthStateMode::Login => {
complete_oauth_login(
state,
headers,
&config.frontend_callback_url,
stored.client_device_id,
claims,
)
.await
}
crate::oauth::IdentityOAuthStateMode::Bind => {
complete_oauth_bind(state, &config.frontend_callback_url, stored, claims).await
}
}
}
async fn handle_oauth_unbind(
state: &AppState,
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
) -> Response<Body> {
let Some(provider_type) =
user_oauth_provider_from_path_without_suffix(&request_context.request_path)
else {
return build_auth_error_response(
http::StatusCode::NOT_FOUND,
"OAuth Provider 不存在",
false,
);
};
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
Ok(value) => value,
Err(response) => return response,
};
match crate::oauth::unbind_identity_oauth(state, &auth.user, &provider_type).await {
Ok(true) => Json(json!({ "message": "解绑成功" })).into_response(),
Ok(false) => {
build_auth_error_response(http::StatusCode::NOT_FOUND, "OAuth 绑定不存在", false)
}
Err(err) => oauth_account_error_response(err),
}
}
async fn start_identity_oauth(
state: &AppState,
provider_type: &str,
client_device_id: String,
mode: crate::oauth::IdentityOAuthStateMode,
bind_user_id: Option<String>,
bind_session_id: Option<String>,
) -> Response<Body> {
let config = match crate::oauth::get_enabled_identity_oauth_provider_config(
state,
provider_type,
)
.await
{
Ok(Some(value)) => value,
Ok(None) => {
return build_auth_error_response(
http::StatusCode::NOT_FOUND,
"OAuth Provider 不存在或已禁用",
false,
)
}
Err(err) => return oauth_account_error_response(err),
};
let pkce_verifier = generate_pkce_verifier();
let code_challenge = pkce_s256(&pkce_verifier);
let stored = match mode {
crate::oauth::IdentityOAuthStateMode::Login => {
crate::oauth::StoredIdentityOAuthState::login(
provider_type,
client_device_id,
Some(pkce_verifier),
)
}
crate::oauth::IdentityOAuthStateMode::Bind => {
let Some(user_id) = bind_user_id else {
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"缺少绑定用户",
false,
);
};
let Some(session_id) = bind_session_id else {
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"缺少绑定会话",
false,
);
};
crate::oauth::StoredIdentityOAuthState::bind(
provider_type,
client_device_id,
Some(pkce_verifier),
user_id,
session_id,
)
}
};
if crate::oauth::save_identity_oauth_state(state, &stored)
.await
.is_err()
{
return build_auth_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
"OAuth 状态存储不可用",
false,
);
}
let network = crate::oauth::resolve_identity_oauth_network_context(state).await;
let start_ctx = IdentityOAuthStartContext {
state: stored.nonce,
code_challenge: Some(code_challenge),
network,
};
let authorize = match IdentityOAuthService::with_builtin_providers().start(&config, &start_ctx)
{
Ok(value) => value,
Err(_) => {
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"OAuth Provider 不可用",
false,
)
}
};
redirect_to(&authorize.authorize_url, None)
}
async fn complete_oauth_login(
state: &AppState,
headers: &http::HeaderMap,
frontend_callback_url: &str,
client_device_id: String,
claims: IdentityClaims,
) -> Response<Body> {
let user = match crate::oauth::resolve_identity_oauth_login_user(state, &claims).await {
Ok(user) if user.is_active && !user.is_deleted => user,
Ok(_) => return redirect_oauth_error(Some(frontend_callback_url), "provider_unavailable"),
Err(err) => return redirect_oauth_error(Some(frontend_callback_url), err.code()),
};
let login_response =
build_auth_login_success_response(state, headers, client_device_id, user).await;
if login_response.status() != http::StatusCode::OK {
return redirect_oauth_error(Some(frontend_callback_url), "provider_unavailable");
}
let set_cookies = login_response
.headers()
.get_all(SET_COOKIE)
.iter()
.cloned()
.collect::<Vec<_>>();
let body = login_response.into_body();
let body = match to_bytes(body, usize::MAX).await {
Ok(value) => value,
Err(_) => return redirect_oauth_error(Some(frontend_callback_url), "provider_unavailable"),
};
let payload = match serde_json::from_slice::<serde_json::Value>(&body) {
Ok(value) => value,
Err(_) => return redirect_oauth_error(Some(frontend_callback_url), "provider_unavailable"),
};
let Some(access_token) = payload
.get("access_token")
.and_then(serde_json::Value::as_str)
else {
return redirect_oauth_error(Some(frontend_callback_url), "provider_unavailable");
};
let expires_in = payload
.get("expires_in")
.and_then(serde_json::Value::as_i64)
.unwrap_or(24 * 60 * 60)
.to_string();
let mut response = redirect_to(
frontend_callback_url,
Some(RedirectParams::Fragment(vec![
("access_token", access_token.to_string()),
("token_type", "bearer".to_string()),
("expires_in", expires_in),
])),
);
for cookie in set_cookies {
response.headers_mut().append(SET_COOKIE, cookie);
}
response
}
async fn complete_oauth_bind(
state: &AppState,
frontend_callback_url: &str,
stored: crate::oauth::StoredIdentityOAuthState,
claims: IdentityClaims,
) -> Response<Body> {
let Some(user_id) = stored.bind_user_id.as_deref() else {
return redirect_oauth_error(Some(frontend_callback_url), "invalid_state");
};
let Some(session_id) = stored.bind_session_id.as_deref() else {
return redirect_oauth_error(Some(frontend_callback_url), "invalid_state");
};
let user = match state.find_user_auth_by_id(user_id).await {
Ok(Some(user)) if user.is_active && !user.is_deleted => user,
_ => return redirect_oauth_error(Some(frontend_callback_url), "invalid_state"),
};
let session = match state.find_user_session(user_id, session_id).await {
Ok(Some(session)) => session,
_ => return redirect_oauth_error(Some(frontend_callback_url), "invalid_state"),
};
let now = chrono::Utc::now();
if session.is_revoked()
|| session.is_expired(now)
|| session.client_device_id != stored.client_device_id
{
return redirect_oauth_error(Some(frontend_callback_url), "invalid_state");
}
if let Err(err) = crate::oauth::bind_identity_oauth_to_user(state, &user, &claims).await {
return redirect_oauth_error(Some(frontend_callback_url), err.code());
}
redirect_to(
frontend_callback_url,
Some(RedirectParams::Query(vec![(
"oauth_bound",
claims.provider_type,
)])),
)
}
#[derive(Debug, Clone)]
struct ValidatedBindToken {
user_id: String,
session_id: String,
}
async fn validate_bind_token(
state: &AppState,
provider_type: &str,
client_device_id: &str,
bind_token: &str,
) -> Result<ValidatedBindToken, Response<Body>> {
let payload = decode_auth_token(bind_token, "oauth_bind").map_err(|detail| {
build_auth_error_response(http::StatusCode::UNAUTHORIZED, detail, false)
})?;
let token_provider = payload
.get("provider_type")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
if token_provider != provider_type {
return Err(build_auth_error_response(
http::StatusCode::UNAUTHORIZED,
"绑定令牌不匹配",
false,
));
}
let Some(user_id) = payload.get("user_id").and_then(serde_json::Value::as_str) else {
return Err(build_auth_error_response(
http::StatusCode::UNAUTHORIZED,
"绑定令牌无效",
false,
));
};
let Some(session_id) = payload
.get("session_id")
.and_then(serde_json::Value::as_str)
else {
return Err(build_auth_error_response(
http::StatusCode::UNAUTHORIZED,
"绑定令牌无效",
false,
));
};
let session = state
.find_user_session(user_id, session_id)
.await
.map_err(|err| {
build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("auth session lookup failed: {err:?}"),
false,
)
})?
.ok_or_else(|| {
build_auth_error_response(http::StatusCode::UNAUTHORIZED, "绑定会话已失效", false)
})?;
if session.is_revoked()
|| session.is_expired(chrono::Utc::now())
|| session.client_device_id != client_device_id
{
return Err(build_auth_error_response(
http::StatusCode::UNAUTHORIZED,
"绑定会话已失效",
false,
));
}
Ok(ValidatedBindToken {
user_id: user_id.to_string(),
session_id: session_id.to_string(),
})
}
fn callback_params(
request_context: &GatewayPublicRequestContext,
) -> std::collections::BTreeMap<String, String> {
request_context
.request_query_string
.as_deref()
.map(|query| {
form_urlencoded::parse(query.as_bytes())
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect()
})
.unwrap_or_default()
}
fn public_oauth_provider_from_path(path: &str, suffix: &str) -> Option<String> {
path.strip_prefix("/api/oauth/")?
.strip_suffix(&format!("/{suffix}"))?
.split('/')
.next()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_ascii_lowercase)
}
fn user_oauth_provider_from_path(path: &str, suffix: &str) -> Option<String> {
path.strip_prefix("/api/user/oauth/")?
.strip_suffix(&format!("/{suffix}"))?
.split('/')
.next()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_ascii_lowercase)
}
fn user_oauth_provider_from_path_without_suffix(path: &str) -> Option<String> {
let provider_type = path.strip_prefix("/api/user/oauth/")?;
(!provider_type.is_empty() && !provider_type.contains('/'))
.then(|| provider_type.trim().to_ascii_lowercase())
}
fn oauth_error_code(error: &OAuthError) -> &'static str {
match error {
OAuthError::InvalidState => "invalid_state",
OAuthError::UnsupportedProvider(_) | OAuthError::InvalidRequest(_) => {
"provider_unavailable"
}
OAuthError::HttpStatus { .. }
| OAuthError::InvalidResponse(_)
| OAuthError::Transport(_) => "token_exchange_failed",
OAuthError::Storage(_) | OAuthError::EncryptionUnavailable => "provider_unavailable",
}
}
fn oauth_account_error_response(error: crate::oauth::IdentityOAuthAccountError) -> Response<Body> {
let status = match error {
crate::oauth::IdentityOAuthAccountError::ProviderUnavailable
| crate::oauth::IdentityOAuthAccountError::Storage(_) => {
http::StatusCode::SERVICE_UNAVAILABLE
}
crate::oauth::IdentityOAuthAccountError::OAuthAlreadyBound
| crate::oauth::IdentityOAuthAccountError::AlreadyBoundProvider
| crate::oauth::IdentityOAuthAccountError::LastOAuthBinding
| crate::oauth::IdentityOAuthAccountError::LastLoginMethod => http::StatusCode::CONFLICT,
_ => http::StatusCode::BAD_REQUEST,
};
build_auth_error_response(status, error.detail(), false)
}
enum RedirectParams {
Query(Vec<(&'static str, String)>),
Fragment(Vec<(&'static str, String)>),
}
fn redirect_oauth_error(frontend_callback_url: Option<&str>, code: &str) -> Response<Body> {
redirect_to(
frontend_callback_url.unwrap_or("/auth/callback"),
Some(RedirectParams::Query(vec![(
"error_code",
code.to_string(),
)])),
)
}
fn redirect_to(target: &str, params: Option<RedirectParams>) -> Response<Body> {
let location = build_redirect_location(target, params);
let mut response = Response::new(Body::empty());
*response.status_mut() = http::StatusCode::FOUND;
if let Ok(value) = HeaderValue::from_str(&location) {
response.headers_mut().insert(LOCATION, value);
}
response
}
fn build_redirect_location(target: &str, params: Option<RedirectParams>) -> String {
let Ok(mut url) = url::Url::parse(target) else {
return target.to_string();
};
match params {
Some(RedirectParams::Query(items)) => {
{
let mut query = url.query_pairs_mut();
for (key, value) in items {
query.append_pair(key, &value);
}
}
url.to_string()
}
Some(RedirectParams::Fragment(items)) => {
let mut serializer = form_urlencoded::Serializer::new(String::new());
for (key, value) in items {
serializer.append_pair(key, &value);
}
url.set_fragment(Some(&serializer.finish()));
url.to_string()
}
None => url.to_string(),
}
}

View File

@@ -48,6 +48,7 @@ mod log_ids;
mod maintenance;
pub(crate) mod middleware;
mod model_fetch;
mod oauth;
mod orchestration;
mod provider_key_auth;
pub(crate) use aether_provider_transport as provider_transport;

View File

@@ -0,0 +1,165 @@
use crate::admin_api::AdminAppState;
use crate::{AppState, GatewayError};
use aether_contracts::{
ExecutionPlan, ExecutionResult, ExecutionTimeouts, RequestBody,
EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER,
};
use aether_oauth::core::OAuthError;
use aether_oauth::network::{OAuthHttpExecutor, OAuthHttpRequest, OAuthHttpResponse};
use async_trait::async_trait;
use base64::{engine::general_purpose::STANDARD, Engine as _};
use flate2::read::{DeflateDecoder, GzDecoder};
use std::collections::BTreeMap;
use std::io::Read;
#[derive(Clone)]
pub(crate) struct GatewayOAuthHttpExecutor<'a> {
app: AppState,
_marker: std::marker::PhantomData<&'a AppState>,
}
impl<'a> GatewayOAuthHttpExecutor<'a> {
pub(crate) fn new(state: AdminAppState<'a>) -> Self {
Self {
app: state.cloned_app(),
_marker: std::marker::PhantomData,
}
}
pub(crate) fn from_app(app: &'a AppState) -> Self {
Self {
app: app.clone(),
_marker: std::marker::PhantomData,
}
}
}
#[async_trait]
impl<'a> OAuthHttpExecutor for GatewayOAuthHttpExecutor<'a> {
async fn execute(&self, request: OAuthHttpRequest) -> Result<OAuthHttpResponse, OAuthError> {
let body = if let Some(json_body) = request.json_body {
RequestBody::from_json(json_body)
} else {
RequestBody {
json_body: None,
body_bytes_b64: request.body_bytes.map(|bytes| STANDARD.encode(bytes)),
body_ref: None,
}
};
let timeouts = request.network.timeouts;
let mut headers = request.headers;
headers
.entry(EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER.to_string())
.or_insert_with(|| "true".to_string());
let plan = ExecutionPlan {
request_id: request.request_id,
candidate_id: None,
provider_name: Some("oauth".to_string()),
provider_id: String::new(),
endpoint_id: String::new(),
key_id: String::new(),
method: request.method.as_str().to_string(),
url: request.url,
headers,
content_type: request.content_type,
content_encoding: None,
body,
stream: false,
client_api_format: "oauth:exchange".to_string(),
provider_api_format: "oauth:exchange".to_string(),
model_name: Some("oauth-exchange".to_string()),
proxy: request.network.proxy,
tls_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(timeouts.connect_ms),
read_ms: Some(timeouts.read_ms),
write_ms: Some(timeouts.write_ms),
pool_ms: Some(timeouts.connect_ms),
total_ms: Some(timeouts.total_ms),
..ExecutionTimeouts::default()
}),
};
let result =
crate::execution_runtime::execute_execution_runtime_sync_plan(&self.app, None, &plan)
.await
.map_err(gateway_error_to_oauth_error)?;
Ok(OAuthHttpResponse {
status_code: result.status_code,
body_text: execution_body_text(&result),
json_body: execution_json_body(&result),
})
}
}
fn execution_json_body(result: &ExecutionResult) -> Option<serde_json::Value> {
result
.body
.as_ref()
.and_then(|body| body.json_body.clone())
.or_else(|| {
result
.body
.as_ref()
.and_then(|body| execution_body_bytes(&result.headers, body))
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
})
}
fn execution_body_text(result: &ExecutionResult) -> String {
result
.body
.as_ref()
.and_then(|body| execution_body_bytes(&result.headers, body))
.map(|bytes| String::from_utf8_lossy(&bytes).to_string())
.or_else(|| {
result
.body
.as_ref()
.and_then(|body| body.json_body.as_ref())
.and_then(|value| serde_json::to_string(value).ok())
})
.unwrap_or_default()
}
fn execution_body_bytes(
headers: &BTreeMap<String, String>,
body: &aether_contracts::ResponseBody,
) -> Option<Vec<u8>> {
let bytes = body
.body_bytes_b64
.as_deref()
.and_then(|value| STANDARD.decode(value).ok())?;
decode_response_bytes(&bytes, headers.get("content-encoding").map(String::as_str))
.or(Some(bytes))
}
fn decode_response_bytes(bytes: &[u8], content_encoding: Option<&str>) -> Option<Vec<u8>> {
match content_encoding
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("gzip") => {
let mut decoder = GzDecoder::new(bytes);
let mut out = Vec::new();
decoder.read_to_end(&mut out).ok()?;
Some(out)
}
Some("deflate") => {
let mut decoder = DeflateDecoder::new(bytes);
let mut out = Vec::new();
decoder.read_to_end(&mut out).ok()?;
Some(out)
}
_ => None,
}
}
fn gateway_error_to_oauth_error(error: GatewayError) -> OAuthError {
match error {
GatewayError::UpstreamUnavailable { message, .. }
| GatewayError::ControlUnavailable { message, .. }
| GatewayError::Internal(message) => OAuthError::Transport(message),
}
}

View File

@@ -0,0 +1,803 @@
use crate::handlers::shared::decrypt_catalog_secret_with_fallbacks;
use crate::{AppState, GatewayError};
use aether_data::repository::oauth_providers::StoredOAuthProviderConfig;
use aether_data::repository::users::StoredUserAuthRecord;
use aether_oauth::identity::{IdentityClaims, IdentityOAuthProviderConfig};
use chrono::{DateTime, Utc};
use serde::Serialize;
use serde_json::{json, Value};
use sqlx::Row;
use uuid::Uuid;
const LINUXDO_AUTHORIZE_URL: &str = "https://connect.linux.do/oauth2/authorize";
const LINUXDO_TOKEN_URL: &str = "https://connect.linux.do/oauth2/token";
const LINUXDO_USERINFO_URL: &str = "https://connect.linux.do/api/user";
const FIND_OAUTH_LINKED_USER_SQL: &str = r#"
SELECT
users.id,
users.email,
users.email_verified,
users.username,
users.password_hash,
users.role::text AS role,
users.auth_source::text AS auth_source,
users.allowed_providers,
users.allowed_api_formats,
users.allowed_models,
users.is_active,
users.is_deleted,
users.created_at,
users.last_login_at
FROM user_oauth_links
JOIN users ON users.id = user_oauth_links.user_id
WHERE user_oauth_links.provider_type = $1
AND user_oauth_links.provider_user_id = $2
LIMIT 1
"#;
const FIND_USER_BY_EMAIL_SQL: &str = r#"
SELECT
id,
email,
email_verified,
username,
password_hash,
role::text AS role,
auth_source::text AS auth_source,
allowed_providers,
allowed_api_formats,
allowed_models,
is_active,
is_deleted,
created_at,
last_login_at
FROM users
WHERE LOWER(email) = LOWER($1)
AND is_deleted IS FALSE
LIMIT 1
"#;
const CHECK_USERNAME_TAKEN_SQL: &str = r#"
SELECT id
FROM users
WHERE username = $1
LIMIT 1
"#;
const CREATE_OAUTH_USER_SQL: &str = r#"
INSERT INTO users (
id,
email,
email_verified,
username,
password_hash,
role,
auth_source,
is_active,
is_deleted,
created_at,
updated_at,
last_login_at
)
VALUES (
$1,
$2,
TRUE,
$3,
NULL,
'user'::userrole,
'oauth'::authsource,
TRUE,
FALSE,
$4,
$4,
$4
)
RETURNING
id,
email,
email_verified,
username,
password_hash,
role::text AS role,
auth_source::text AS auth_source,
allowed_providers,
allowed_api_formats,
allowed_models,
is_active,
is_deleted,
created_at,
last_login_at
"#;
const UPSERT_OAUTH_LINK_SQL: &str = r#"
INSERT INTO user_oauth_links (
id,
user_id,
provider_type,
provider_user_id,
provider_username,
provider_email,
extra_data,
linked_at,
last_login_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
ON CONFLICT (user_id, provider_type) DO UPDATE
SET provider_user_id = EXCLUDED.provider_user_id,
provider_username = EXCLUDED.provider_username,
provider_email = EXCLUDED.provider_email,
extra_data = EXCLUDED.extra_data,
last_login_at = EXCLUDED.last_login_at
"#;
const TOUCH_OAUTH_LINK_SQL: &str = r#"
UPDATE user_oauth_links
SET provider_username = COALESCE($3, provider_username),
provider_email = COALESCE($4, provider_email),
extra_data = COALESCE($5, extra_data),
last_login_at = $6
WHERE provider_type = $1
AND provider_user_id = $2
"#;
const CREATE_AUTH_USER_WALLET_SQL: &str = r#"
INSERT INTO wallets (
id,
user_id,
api_key_id,
balance,
gift_balance,
limit_mode,
currency,
status,
total_recharged,
total_consumed,
total_refunded,
total_adjusted,
created_at,
updated_at
)
VALUES (
$1,
$2,
NULL,
0,
$3,
$4,
'USD',
'active',
0,
0,
0,
$3,
NOW(),
NOW()
)
"#;
const CREATE_AUTH_USER_WALLET_GIFT_TX_SQL: &str = r#"
INSERT INTO wallet_transactions (
id,
wallet_id,
category,
reason_code,
amount,
balance_before,
balance_after,
recharge_balance_before,
recharge_balance_after,
gift_balance_before,
gift_balance_after,
link_type,
link_id,
operator_id,
description,
created_at
)
VALUES (
$1,
$2,
'gift',
'gift_initial',
$3,
0,
$3,
0,
0,
0,
$3,
'system_task',
$4,
NULL,
'用户初始赠款',
NOW()
)
"#;
const LIST_OAUTH_LINKS_SQL: &str = r#"
SELECT
user_oauth_links.provider_type,
oauth_providers.display_name,
user_oauth_links.provider_username,
user_oauth_links.provider_email,
user_oauth_links.linked_at,
user_oauth_links.last_login_at,
oauth_providers.is_enabled AS provider_enabled
FROM user_oauth_links
JOIN oauth_providers
ON oauth_providers.provider_type = user_oauth_links.provider_type
WHERE user_oauth_links.user_id = $1
ORDER BY user_oauth_links.linked_at ASC
"#;
const FIND_OAUTH_LINK_OWNER_SQL: &str = r#"
SELECT user_id
FROM user_oauth_links
WHERE provider_type = $1
AND provider_user_id = $2
LIMIT 1
"#;
const FIND_USER_PROVIDER_LINK_OWNER_SQL: &str = r#"
SELECT user_id
FROM user_oauth_links
WHERE user_id = $1
AND provider_type = $2
LIMIT 1
"#;
const COUNT_USER_OAUTH_LINKS_SQL: &str = r#"
SELECT COUNT(*)::bigint AS link_count
FROM user_oauth_links
WHERE user_id = $1
"#;
const DELETE_USER_OAUTH_LINK_SQL: &str = r#"
DELETE FROM user_oauth_links
WHERE user_id = $1
AND provider_type = $2
"#;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct IdentityOAuthProviderSummary {
pub(crate) provider_type: String,
pub(crate) display_name: String,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(crate) struct IdentityOAuthLinkSummary {
pub(crate) provider_type: String,
pub(crate) display_name: String,
pub(crate) provider_username: Option<String>,
pub(crate) provider_email: Option<String>,
pub(crate) linked_at: Option<String>,
pub(crate) last_login_at: Option<String>,
pub(crate) provider_enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum IdentityOAuthAccountError {
ProviderUnavailable,
RegistrationDisabled,
EmailExistsLocal,
EmailIsLdap,
EmailIsOauth,
OAuthAlreadyBound,
AlreadyBoundProvider,
LastOAuthBinding,
LastLoginMethod,
Storage(String),
}
impl IdentityOAuthAccountError {
pub(crate) fn code(&self) -> &'static str {
match self {
Self::ProviderUnavailable | Self::Storage(_) => "provider_unavailable",
Self::RegistrationDisabled => "registration_disabled",
Self::EmailExistsLocal => "email_exists_local",
Self::EmailIsLdap => "email_is_ldap",
Self::EmailIsOauth => "email_is_oauth",
Self::OAuthAlreadyBound => "oauth_already_bound",
Self::AlreadyBoundProvider => "already_bound_provider",
Self::LastOAuthBinding => "last_oauth_binding",
Self::LastLoginMethod => "last_login_method",
}
}
pub(crate) fn detail(&self) -> String {
match self {
Self::Storage(message) => message.clone(),
_ => self.code().to_string(),
}
}
}
pub(crate) async fn list_enabled_identity_oauth_providers(
state: &AppState,
) -> Result<Vec<IdentityOAuthProviderSummary>, GatewayError> {
let mut providers = state
.list_oauth_provider_configs()
.await?
.into_iter()
.filter(|provider| provider.is_enabled)
.map(|provider| IdentityOAuthProviderSummary {
provider_type: provider.provider_type,
display_name: provider.display_name,
})
.collect::<Vec<_>>();
providers.sort_by(|left, right| left.provider_type.cmp(&right.provider_type));
Ok(providers)
}
pub(crate) async fn get_enabled_identity_oauth_provider_config(
state: &AppState,
provider_type: &str,
) -> Result<Option<IdentityOAuthProviderConfig>, IdentityOAuthAccountError> {
let provider_type = provider_type.trim().to_ascii_lowercase();
let config = state
.get_oauth_provider_config(&provider_type)
.await
.map_err(|err| IdentityOAuthAccountError::Storage(format!("{err:?}")))?;
let Some(config) = config.filter(|config| config.is_enabled) else {
return Ok(None);
};
stored_provider_config_to_identity_config(state, config).map(Some)
}
pub(crate) async fn list_identity_oauth_links(
state: &AppState,
user_id: &str,
) -> Result<Vec<IdentityOAuthLinkSummary>, GatewayError> {
let Some(pool) = state.postgres_pool() else {
return Ok(Vec::new());
};
let rows = sqlx::query(LIST_OAUTH_LINKS_SQL)
.bind(user_id)
.fetch_all(&pool)
.await
.map_err(sql_gateway_error)?;
rows.iter().map(map_link_summary_row).collect()
}
pub(crate) async fn list_bindable_identity_oauth_providers(
state: &AppState,
user_id: &str,
) -> Result<Vec<IdentityOAuthProviderSummary>, GatewayError> {
let linked = list_identity_oauth_links(state, user_id)
.await?
.into_iter()
.map(|link| link.provider_type)
.collect::<std::collections::BTreeSet<_>>();
let providers = list_enabled_identity_oauth_providers(state)
.await?
.into_iter()
.filter(|provider| !linked.contains(&provider.provider_type))
.collect();
Ok(providers)
}
pub(crate) async fn resolve_identity_oauth_login_user(
state: &AppState,
claims: &IdentityClaims,
) -> Result<StoredUserAuthRecord, IdentityOAuthAccountError> {
let Some(pool) = state.postgres_pool() else {
return Err(IdentityOAuthAccountError::ProviderUnavailable);
};
let now = Utc::now();
if let Some(row) = sqlx::query(FIND_OAUTH_LINKED_USER_SQL)
.bind(&claims.provider_type)
.bind(&claims.subject)
.fetch_optional(&pool)
.await
.map_err(repo_sql_error)?
{
sqlx::query(TOUCH_OAUTH_LINK_SQL)
.bind(&claims.provider_type)
.bind(&claims.subject)
.bind(claims.username.as_deref())
.bind(claims.email.as_deref())
.bind(Some(claims.raw.clone()))
.bind(now)
.execute(&pool)
.await
.map_err(repo_sql_error)?;
return map_user_auth_row(&row).map_err(repo_data_error);
}
let email = normalize_identity_email(claims.email.as_deref());
if let Some(email) = email.as_deref() {
if let Some(row) = sqlx::query(FIND_USER_BY_EMAIL_SQL)
.bind(email)
.fetch_optional(&pool)
.await
.map_err(repo_sql_error)?
{
let existing = map_user_auth_row(&row).map_err(repo_data_error)?;
return Err(match existing.auth_source.to_ascii_lowercase().as_str() {
"local" => IdentityOAuthAccountError::EmailExistsLocal,
"ldap" => IdentityOAuthAccountError::EmailIsLdap,
_ => IdentityOAuthAccountError::EmailIsOauth,
});
}
}
let registration_enabled = state
.read_system_config_json_value("enable_registration")
.await
.map_err(|err| IdentityOAuthAccountError::Storage(format!("{err:?}")))?
.as_ref()
.map(system_config_bool)
.unwrap_or(false);
if !registration_enabled {
return Err(IdentityOAuthAccountError::RegistrationDisabled);
}
let initial_gift = state
.read_system_config_json_value("default_user_initial_gift_usd")
.await
.map_err(|err| IdentityOAuthAccountError::Storage(format!("{err:?}")))?
.as_ref()
.map(|value| system_config_f64(value, 10.0))
.unwrap_or(10.0);
let mut tx = pool.begin().await.map_err(repo_sql_error)?;
let username = unique_oauth_username(&mut tx, claims).await?;
let user_id = Uuid::new_v4().to_string();
let row = sqlx::query(CREATE_OAUTH_USER_SQL)
.bind(&user_id)
.bind(email.as_deref())
.bind(&username)
.bind(now)
.fetch_one(&mut *tx)
.await
.map_err(repo_sql_error)?;
let user = map_user_auth_row(&row).map_err(repo_data_error)?;
create_initial_wallet_in_tx(&mut tx, &user.id, initial_gift).await?;
upsert_oauth_link_in_tx(&mut tx, &user.id, claims, now).await?;
tx.commit().await.map_err(repo_sql_error)?;
Ok(user)
}
pub(crate) async fn bind_identity_oauth_to_user(
state: &AppState,
user: &StoredUserAuthRecord,
claims: &IdentityClaims,
) -> Result<(), IdentityOAuthAccountError> {
if user.auth_source.eq_ignore_ascii_case("ldap") {
return Err(IdentityOAuthAccountError::EmailIsLdap);
}
let Some(pool) = state.postgres_pool() else {
return Err(IdentityOAuthAccountError::ProviderUnavailable);
};
if let Some(row) = sqlx::query(FIND_OAUTH_LINK_OWNER_SQL)
.bind(&claims.provider_type)
.bind(&claims.subject)
.fetch_optional(&pool)
.await
.map_err(repo_sql_error)?
{
let owner: String = row.try_get("user_id").map_err(repo_sql_error)?;
if owner != user.id {
return Err(IdentityOAuthAccountError::OAuthAlreadyBound);
}
}
if sqlx::query(FIND_USER_PROVIDER_LINK_OWNER_SQL)
.bind(&user.id)
.bind(&claims.provider_type)
.fetch_optional(&pool)
.await
.map_err(repo_sql_error)?
.is_some()
{
return Err(IdentityOAuthAccountError::AlreadyBoundProvider);
}
let mut tx = pool.begin().await.map_err(repo_sql_error)?;
upsert_oauth_link_in_tx(&mut tx, &user.id, claims, Utc::now()).await?;
tx.commit().await.map_err(repo_sql_error)?;
Ok(())
}
pub(crate) async fn unbind_identity_oauth(
state: &AppState,
user: &StoredUserAuthRecord,
provider_type: &str,
) -> Result<bool, IdentityOAuthAccountError> {
if user.auth_source.eq_ignore_ascii_case("ldap") {
return Err(IdentityOAuthAccountError::EmailIsLdap);
}
let Some(pool) = state.postgres_pool() else {
return Err(IdentityOAuthAccountError::ProviderUnavailable);
};
let row = sqlx::query(COUNT_USER_OAUTH_LINKS_SQL)
.bind(&user.id)
.fetch_one(&pool)
.await
.map_err(repo_sql_error)?;
let link_count: i64 = row.try_get("link_count").map_err(repo_sql_error)?;
if user.auth_source.eq_ignore_ascii_case("oauth") && link_count <= 1 {
return Err(IdentityOAuthAccountError::LastOAuthBinding);
}
if !user.auth_source.eq_ignore_ascii_case("local") && link_count <= 1 {
return Err(IdentityOAuthAccountError::LastLoginMethod);
}
let result = sqlx::query(DELETE_USER_OAUTH_LINK_SQL)
.bind(&user.id)
.bind(provider_type.trim())
.execute(&pool)
.await
.map_err(repo_sql_error)?;
Ok(result.rows_affected() > 0)
}
fn stored_provider_config_to_identity_config(
state: &AppState,
config: StoredOAuthProviderConfig,
) -> Result<IdentityOAuthProviderConfig, IdentityOAuthAccountError> {
let defaults = identity_provider_defaults(&config.provider_type);
let authorization_url = config
.authorization_url_override
.clone()
.or_else(|| defaults.map(|defaults| defaults.0.to_string()))
.filter(|value| !value.trim().is_empty())
.ok_or(IdentityOAuthAccountError::ProviderUnavailable)?;
let token_url = config
.token_url_override
.clone()
.or_else(|| defaults.map(|defaults| defaults.1.to_string()))
.filter(|value| !value.trim().is_empty())
.ok_or(IdentityOAuthAccountError::ProviderUnavailable)?;
let userinfo_url = config
.userinfo_url_override
.clone()
.or_else(|| defaults.map(|defaults| defaults.2.to_string()));
let client_secret = match config.client_secret_encrypted.as_deref() {
Some(ciphertext) => Some(
decrypt_catalog_secret_with_fallbacks(state.encryption_key(), ciphertext)
.ok_or(IdentityOAuthAccountError::ProviderUnavailable)?,
),
None => None,
};
Ok(IdentityOAuthProviderConfig {
provider_type: config.provider_type,
display_name: config.display_name,
authorization_url,
token_url,
userinfo_url,
client_id: config.client_id,
client_secret,
scopes: config.scopes.unwrap_or_default(),
redirect_uri: config.redirect_uri,
frontend_callback_url: config.frontend_callback_url,
attribute_mapping: config.attribute_mapping,
extra_config: config.extra_config,
})
}
fn identity_provider_defaults(
provider_type: &str,
) -> Option<(&'static str, &'static str, &'static str)> {
match provider_type.trim().to_ascii_lowercase().as_str() {
"linuxdo" => Some((
LINUXDO_AUTHORIZE_URL,
LINUXDO_TOKEN_URL,
LINUXDO_USERINFO_URL,
)),
_ => None,
}
}
fn map_link_summary_row(
row: &sqlx::postgres::PgRow,
) -> Result<IdentityOAuthLinkSummary, GatewayError> {
Ok(IdentityOAuthLinkSummary {
provider_type: row.try_get("provider_type").map_err(sql_gateway_error)?,
display_name: row.try_get("display_name").map_err(sql_gateway_error)?,
provider_username: row
.try_get("provider_username")
.map_err(sql_gateway_error)?,
provider_email: row.try_get("provider_email").map_err(sql_gateway_error)?,
linked_at: row
.try_get::<Option<DateTime<Utc>>, _>("linked_at")
.map_err(sql_gateway_error)?
.map(|value| value.to_rfc3339()),
last_login_at: row
.try_get::<Option<DateTime<Utc>>, _>("last_login_at")
.map_err(sql_gateway_error)?
.map(|value| value.to_rfc3339()),
provider_enabled: row.try_get("provider_enabled").map_err(sql_gateway_error)?,
})
}
fn map_user_auth_row(
row: &sqlx::postgres::PgRow,
) -> Result<StoredUserAuthRecord, aether_data::DataLayerError> {
StoredUserAuthRecord::new(
row.try_get("id").map_err(data_unexpected)?,
row.try_get("email").map_err(data_unexpected)?,
row.try_get("email_verified").map_err(data_unexpected)?,
row.try_get("username").map_err(data_unexpected)?,
row.try_get("password_hash").map_err(data_unexpected)?,
row.try_get("role").map_err(data_unexpected)?,
row.try_get("auth_source").map_err(data_unexpected)?,
row.try_get("allowed_providers").map_err(data_unexpected)?,
row.try_get("allowed_api_formats")
.map_err(data_unexpected)?,
row.try_get("allowed_models").map_err(data_unexpected)?,
row.try_get("is_active").map_err(data_unexpected)?,
row.try_get("is_deleted").map_err(data_unexpected)?,
row.try_get("created_at").map_err(data_unexpected)?,
row.try_get("last_login_at").map_err(data_unexpected)?,
)
}
async fn unique_oauth_username(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
claims: &IdentityClaims,
) -> Result<String, IdentityOAuthAccountError> {
let base = normalize_oauth_username(
claims
.username
.as_deref()
.or(claims.display_name.as_deref())
.or_else(|| {
claims
.email
.as_deref()
.and_then(|email| email.split('@').next())
})
.unwrap_or("oauth_user"),
);
for attempt in 0..8 {
let candidate = if attempt == 0 {
base.clone()
} else {
format!(
"{}_{}",
base.chars().take(20).collect::<String>(),
short_uuid()
)
};
let taken = sqlx::query(CHECK_USERNAME_TAKEN_SQL)
.bind(&candidate)
.fetch_optional(&mut **tx)
.await
.map_err(repo_sql_error)?
.is_some();
if !taken {
return Ok(candidate);
}
}
Ok(format!("oauth_{}", short_uuid()))
}
async fn upsert_oauth_link_in_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
user_id: &str,
claims: &IdentityClaims,
now: DateTime<Utc>,
) -> Result<(), IdentityOAuthAccountError> {
sqlx::query(UPSERT_OAUTH_LINK_SQL)
.bind(Uuid::new_v4().to_string())
.bind(user_id)
.bind(&claims.provider_type)
.bind(&claims.subject)
.bind(claims.username.as_deref())
.bind(claims.email.as_deref())
.bind(Some(claims.raw.clone()))
.bind(now)
.execute(&mut **tx)
.await
.map_err(repo_sql_error)?;
Ok(())
}
async fn create_initial_wallet_in_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
user_id: &str,
initial_gift_usd: f64,
) -> Result<(), IdentityOAuthAccountError> {
let gift_amount = initial_gift_usd.max(0.0);
let wallet_id = Uuid::new_v4().to_string();
sqlx::query(CREATE_AUTH_USER_WALLET_SQL)
.bind(&wallet_id)
.bind(user_id)
.bind(gift_amount)
.bind("finite")
.execute(&mut **tx)
.await
.map_err(repo_sql_error)?;
if gift_amount > 0.0 {
sqlx::query(CREATE_AUTH_USER_WALLET_GIFT_TX_SQL)
.bind(Uuid::new_v4().to_string())
.bind(&wallet_id)
.bind(gift_amount)
.bind(user_id)
.execute(&mut **tx)
.await
.map_err(repo_sql_error)?;
}
Ok(())
}
fn normalize_identity_email(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_ascii_lowercase)
}
fn normalize_oauth_username(value: &str) -> String {
let mut normalized = value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
ch
} else {
'_'
}
})
.collect::<String>();
while normalized.contains("__") {
normalized = normalized.replace("__", "_");
}
normalized = normalized
.trim_matches(|ch| matches!(ch, '_' | '-' | '.'))
.chars()
.take(30)
.collect();
if normalized.len() < 3 || is_reserved_username(&normalized) {
normalized = format!("oauth_{}", short_uuid());
}
normalized
}
fn is_reserved_username(value: &str) -> bool {
matches!(
value.to_ascii_lowercase().as_str(),
"admin" | "root" | "system" | "api" | "test" | "demo" | "user" | "guest" | "bot"
)
}
fn short_uuid() -> String {
Uuid::new_v4().simple().to_string()[..8].to_string()
}
fn system_config_bool(value: &Value) -> bool {
match value {
Value::Bool(value) => *value,
Value::Number(value) => value.as_i64().is_some_and(|value| value != 0),
Value::String(value) => matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
),
_ => false,
}
}
fn system_config_f64(value: &Value, default: f64) -> f64 {
match value {
Value::Number(value) => value.as_f64().unwrap_or(default),
Value::String(value) => value.trim().parse::<f64>().unwrap_or(default),
_ => default,
}
}
fn repo_sql_error(error: sqlx::Error) -> IdentityOAuthAccountError {
IdentityOAuthAccountError::Storage(error.to_string())
}
fn repo_data_error(error: aether_data::DataLayerError) -> IdentityOAuthAccountError {
IdentityOAuthAccountError::Storage(error.to_string())
}
fn sql_gateway_error(error: sqlx::Error) -> GatewayError {
GatewayError::Internal(error.to_string())
}
fn data_unexpected(error: sqlx::Error) -> aether_data::DataLayerError {
aether_data::DataLayerError::UnexpectedValue(error.to_string())
}

View File

@@ -0,0 +1,21 @@
mod http_executor;
mod identity_repo;
mod provider_repo;
mod proxy;
mod state_store;
pub(crate) use http_executor::GatewayOAuthHttpExecutor;
pub(crate) use identity_repo::{
bind_identity_oauth_to_user, get_enabled_identity_oauth_provider_config,
list_bindable_identity_oauth_providers, list_enabled_identity_oauth_providers,
list_identity_oauth_links, resolve_identity_oauth_login_user, unbind_identity_oauth,
IdentityOAuthAccountError,
};
pub(crate) use provider_repo::ProviderOAuthRepository;
pub(crate) use proxy::{
resolve_identity_oauth_network_context, resolve_provider_oauth_operation_proxy_snapshot,
};
pub(crate) use state_store::{
consume_identity_oauth_state, save_identity_oauth_state, IdentityOAuthStateMode,
StoredIdentityOAuthState,
};

View File

@@ -0,0 +1,122 @@
use crate::admin_api::{
create_provider_oauth_catalog_key, find_duplicate_provider_oauth_key,
refresh_provider_oauth_account_state_after_update, update_existing_provider_oauth_catalog_key,
AdminAppState, AdminGatewayProviderTransportSnapshot, AdminLocalOAuthRefreshError,
};
use crate::GatewayError;
use aether_contracts::ProxySnapshot;
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct ProviderOAuthRepository;
impl ProviderOAuthRepository {
pub(crate) async fn update_provider_catalog_key_oauth_credentials(
state: &AdminAppState<'_>,
key_id: &str,
encrypted_api_key: &str,
encrypted_auth_config: Option<&str>,
expires_at_unix_secs: Option<u64>,
) -> Result<bool, GatewayError> {
state
.app()
.update_provider_catalog_key_oauth_credentials(
key_id,
encrypted_api_key,
encrypted_auth_config,
expires_at_unix_secs,
)
.await
}
pub(crate) async fn clear_provider_catalog_key_oauth_invalid_marker(
state: &AdminAppState<'_>,
key_id: &str,
) -> Result<bool, GatewayError> {
state
.app()
.clear_provider_catalog_key_oauth_invalid_marker(key_id)
.await
}
pub(crate) async fn force_local_oauth_refresh_entry(
state: &AdminAppState<'_>,
transport: &AdminGatewayProviderTransportSnapshot,
) -> Result<Option<crate::provider_transport::CachedOAuthEntry>, AdminLocalOAuthRefreshError>
{
state.app().force_local_oauth_refresh_entry(transport).await
}
pub(crate) async fn find_duplicate_provider_oauth_key(
state: &AdminAppState<'_>,
provider_id: &str,
auth_config: &serde_json::Map<String, serde_json::Value>,
exclude_key_id: Option<&str>,
) -> Result<Option<StoredProviderCatalogKey>, String> {
find_duplicate_provider_oauth_key(state, provider_id, auth_config, exclude_key_id).await
}
pub(crate) async fn create_provider_oauth_catalog_key(
state: &AdminAppState<'_>,
provider_id: &str,
provider_type: &str,
name: &str,
access_token: &str,
auth_config: &serde_json::Map<String, serde_json::Value>,
api_formats: &[String],
proxy: Option<serde_json::Value>,
expires_at_unix_secs: Option<u64>,
) -> Result<Option<StoredProviderCatalogKey>, GatewayError> {
create_provider_oauth_catalog_key(
state,
provider_id,
provider_type,
name,
access_token,
auth_config,
api_formats,
proxy,
expires_at_unix_secs,
)
.await
}
pub(crate) async fn update_existing_provider_oauth_catalog_key(
state: &AdminAppState<'_>,
existing_key: &StoredProviderCatalogKey,
provider_type: &str,
access_token: &str,
auth_config: &serde_json::Map<String, serde_json::Value>,
api_formats: &[String],
proxy: Option<serde_json::Value>,
expires_at_unix_secs: Option<u64>,
) -> Result<Option<StoredProviderCatalogKey>, GatewayError> {
update_existing_provider_oauth_catalog_key(
state,
existing_key,
provider_type,
access_token,
auth_config,
api_formats,
proxy,
expires_at_unix_secs,
)
.await
}
pub(crate) async fn refresh_provider_oauth_account_state_after_update(
state: &AdminAppState<'_>,
provider: &StoredProviderCatalogProvider,
key_id: &str,
proxy_override: Option<&ProxySnapshot>,
) -> Result<(bool, Option<String>), GatewayError> {
refresh_provider_oauth_account_state_after_update(state, provider, key_id, proxy_override)
.await
}
pub(crate) fn clear_transport_cache_after_write(state: &AdminAppState<'_>) {
state.app().clear_provider_transport_snapshot_cache();
}
}

View File

@@ -0,0 +1,44 @@
use crate::admin_api::AdminAppState;
use crate::AppState;
use aether_contracts::ProxySnapshot;
use aether_oauth::network::{OAuthNetworkContext, OAuthNetworkPolicy, OAuthTimeouts};
pub(crate) async fn resolve_identity_oauth_network_context(
state: &AppState,
) -> OAuthNetworkContext {
let proxy = state.resolve_system_proxy_snapshot().await;
OAuthNetworkContext {
policy: OAuthNetworkPolicy::DirectOrSystemProxy,
requirement: aether_oauth::network::NetworkRequirement::Optional,
timeouts: if proxy.is_some() {
OAuthTimeouts::PROXY_DEFAULT
} else {
OAuthTimeouts::DIRECT_DEFAULT
},
proxy,
}
}
pub(crate) async fn resolve_provider_oauth_operation_proxy_snapshot(
state: &AdminAppState<'_>,
temporary_proxy_node_id: Option<&str>,
configured_proxies: &[Option<&serde_json::Value>],
) -> Option<ProxySnapshot> {
if let Some(snapshot) = state
.resolve_admin_proxy_node_snapshot(temporary_proxy_node_id)
.await
{
return Some(snapshot);
}
for proxy in configured_proxies {
if let Some(snapshot) = state
.app()
.resolve_configured_proxy_snapshot_with_tunnel_affinity(*proxy)
.await
{
return Some(snapshot);
}
}
state.app().resolve_system_proxy_snapshot().await
}

View File

@@ -0,0 +1,118 @@
use crate::{AppState, GatewayError};
use aether_oauth::core::{current_unix_secs, generate_oauth_nonce};
use serde::{Deserialize, Serialize};
const IDENTITY_OAUTH_STATE_TTL_SECS: u64 = 10 * 60;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum IdentityOAuthStateMode {
Login,
Bind,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct StoredIdentityOAuthState {
pub(crate) nonce: String,
pub(crate) provider_type: String,
pub(crate) mode: IdentityOAuthStateMode,
pub(crate) client_device_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) pkce_verifier: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) bind_user_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) bind_session_id: Option<String>,
pub(crate) created_at: u64,
}
impl StoredIdentityOAuthState {
pub(crate) fn login(
provider_type: impl Into<String>,
client_device_id: impl Into<String>,
pkce_verifier: Option<String>,
) -> Self {
Self {
nonce: generate_oauth_nonce(),
provider_type: provider_type.into(),
mode: IdentityOAuthStateMode::Login,
client_device_id: client_device_id.into(),
pkce_verifier,
bind_user_id: None,
bind_session_id: None,
created_at: current_unix_secs(),
}
}
pub(crate) fn bind(
provider_type: impl Into<String>,
client_device_id: impl Into<String>,
pkce_verifier: Option<String>,
user_id: impl Into<String>,
session_id: impl Into<String>,
) -> Self {
Self {
nonce: generate_oauth_nonce(),
provider_type: provider_type.into(),
mode: IdentityOAuthStateMode::Bind,
client_device_id: client_device_id.into(),
pkce_verifier,
bind_user_id: Some(user_id.into()),
bind_session_id: Some(session_id.into()),
created_at: current_unix_secs(),
}
}
}
pub(crate) fn identity_oauth_state_storage_key(nonce: &str) -> String {
format!("identity_oauth_state:{}", nonce.trim())
}
pub(crate) async fn save_identity_oauth_state(
state: &AppState,
record: &StoredIdentityOAuthState,
) -> Result<(), GatewayError> {
let key = identity_oauth_state_storage_key(&record.nonce);
let value =
serde_json::to_string(record).map_err(|err| GatewayError::Internal(err.to_string()))?;
if let Some(runner) = state.redis_kv_runner() {
runner
.setex(&key, &value, Some(IDENTITY_OAUTH_STATE_TTL_SECS))
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
return Ok(());
}
if state.save_provider_oauth_state_for_tests(&key, &value) {
return Ok(());
}
Err(GatewayError::Internal(
"identity oauth state store unavailable".to_string(),
))
}
pub(crate) async fn consume_identity_oauth_state(
state: &AppState,
nonce: &str,
) -> Result<Option<StoredIdentityOAuthState>, GatewayError> {
let key = identity_oauth_state_storage_key(nonce);
let raw = if let Some(runner) = state.redis_kv_runner() {
let mut connection = runner
.client()
.get_multiplexed_async_connection()
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let namespaced_key = runner.keyspace().key(&key);
redis::cmd("GETDEL")
.arg(&namespaced_key)
.query_async::<Option<String>>(&mut connection)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
} else {
state.take_provider_oauth_state_for_tests(&key)
};
raw.map(|value| {
serde_json::from_str::<StoredIdentityOAuthState>(&value)
.map_err(|err| GatewayError::Internal(err.to_string()))
})
.transpose()
}

View File

@@ -5675,9 +5675,11 @@ async fn gateway_handles_admin_oauth_supported_types_locally_with_trusted_admin_
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
let items = payload.as_array().expect("items should be array");
assert_eq!(items.len(), 1);
assert_eq!(items.len(), 2);
assert_eq!(items[0]["provider_type"], "linuxdo");
assert_eq!(items[0]["display_name"], "Linux Do");
assert_eq!(items[1]["provider_type"], "custom_oidc");
assert_eq!(items[1]["display_name"], "Custom OIDC");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
@@ -5802,6 +5804,152 @@ async fn gateway_upserts_admin_oauth_provider_locally_with_trusted_admin_princip
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_custom_oidc_without_allowed_domains() {
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/custom_oidc",
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()
.put(format!(
"{gateway_url}/api/admin/oauth/providers/custom_oidc"
))
.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": "Custom OIDC",
"client_id": "custom-client",
"authorization_url_override": "https://idp.example.com/oauth/authorize",
"token_url_override": "https://idp.example.com/oauth/token",
"userinfo_url_override": "https://idp.example.com/oauth/userinfo",
"scopes": ["openid", "profile", "email"],
"redirect_uri": "https://backend.example.com/oauth/callback",
"frontend_callback_url": "https://frontend.example.com/auth/callback",
"attribute_mapping": {"sub": "sub", "email": "email"},
"extra_config": {},
"is_enabled": true
}))
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(
payload["error"]["message"],
"custom_oidc 必须在 extra_config.allowed_domains 配置域名白名单"
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_upserts_custom_oidc_with_allowed_domains() {
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/custom_oidc",
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;
let response = reqwest::Client::new()
.put(format!(
"{gateway_url}/api/admin/oauth/providers/custom_oidc"
))
.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": "Custom OIDC",
"client_id": "custom-client",
"authorization_url_override": "https://idp.example.com/oauth/authorize",
"token_url_override": "https://idp.example.com/oauth/token",
"userinfo_url_override": "https://idp.example.com/oauth/userinfo",
"scopes": ["openid", "profile", "email"],
"redirect_uri": "https://backend.example.com/oauth/callback",
"frontend_callback_url": "https://frontend.example.com/auth/callback",
"attribute_mapping": {"sub": "id", "email": "profile.email"},
"extra_config": {"allowed_domains": ["idp.example.com"]},
"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"], "custom_oidc");
assert_eq!(payload["is_enabled"], true);
let stored = repository
.get_oauth_provider_config("custom_oidc")
.await
.expect("lookup should succeed")
.expect("provider should exist");
assert_eq!(
stored.authorization_url_override.as_deref(),
Some("https://idp.example.com/oauth/authorize")
);
assert_eq!(
stored
.extra_config
.as_ref()
.and_then(|value| {
value
.get("allowed_domains")
.and_then(serde_json::Value::as_array)
})
.map(Vec::len),
Some(1)
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_deletes_admin_oauth_provider_locally_with_trusted_admin_principal() {
let upstream_hits = Arc::new(Mutex::new(0usize));

View File

@@ -3,7 +3,7 @@ use crate::tests::{
};
#[tokio::test]
async fn gateway_rejects_oauth_public_providers_as_local_not_found_without_hitting_upstream() {
async fn gateway_serves_oauth_public_providers_locally_without_hitting_upstream() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
@@ -27,10 +27,9 @@ async fn gateway_rejects_oauth_public_providers_as_local_not_found_without_hitti
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["error"]["type"], "http_error");
assert_eq!(payload["error"]["message"], "Route not found");
assert_eq!(payload["providers"].as_array().map(Vec::len), Some(0));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
@@ -38,8 +37,42 @@ async fn gateway_rejects_oauth_public_providers_as_local_not_found_without_hitti
}
#[tokio::test]
async fn gateway_rejects_oauth_user_bindable_providers_as_local_not_found_without_hitting_upstream()
{
async fn gateway_accepts_oauth_authorize_device_id_header_without_hitting_upstream() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router().expect("gateway should build");
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/oauth/linuxdo/authorize"))
.header("x-client-device-id", "device-123")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["detail"], "OAuth Provider 不存在或已禁用");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_requires_auth_for_oauth_user_bindable_providers_without_hitting_upstream() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
@@ -63,10 +96,9 @@ async fn gateway_rejects_oauth_user_bindable_providers_as_local_not_found_withou
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["error"]["type"], "http_error");
assert_eq!(payload["error"]["message"], "Route not found");
assert_eq!(payload["detail"], "缺少用户凭证");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
@@ -74,7 +106,7 @@ async fn gateway_rejects_oauth_user_bindable_providers_as_local_not_found_withou
}
#[tokio::test]
async fn gateway_rejects_oauth_user_bind_token_as_local_not_found_without_hitting_upstream() {
async fn gateway_requires_auth_for_oauth_user_bind_token_without_hitting_upstream() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
@@ -98,10 +130,9 @@ async fn gateway_rejects_oauth_user_bind_token_as_local_not_found_without_hittin
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["error"]["type"], "http_error");
assert_eq!(payload["error"]["message"], "Route not found");
assert_eq!(payload["detail"], "缺少用户凭证");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();