mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
Add shared OAuth flows
This commit is contained in:
20
Cargo.lock
generated
20
Cargo.lock
generated
@@ -159,6 +159,7 @@ dependencies = [
|
|||||||
"aether-data-contracts",
|
"aether-data-contracts",
|
||||||
"aether-http",
|
"aether-http",
|
||||||
"aether-model-fetch",
|
"aether-model-fetch",
|
||||||
|
"aether-oauth",
|
||||||
"aether-provider-transport",
|
"aether-provider-transport",
|
||||||
"aether-runtime",
|
"aether-runtime",
|
||||||
"aether-scheduler-core",
|
"aether-scheduler-core",
|
||||||
@@ -231,6 +232,24 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aether-oauth"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"aether-contracts",
|
||||||
|
"async-trait",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"http",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"url",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aether-provider-transport"
|
name = "aether-provider-transport"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -240,6 +259,7 @@ dependencies = [
|
|||||||
"aether-crypto",
|
"aether-crypto",
|
||||||
"aether-data",
|
"aether-data",
|
||||||
"aether-data-contracts",
|
"aether-data-contracts",
|
||||||
|
"aether-oauth",
|
||||||
"aether-video-tasks-core",
|
"aether-video-tasks-core",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ members = [
|
|||||||
"crates/aether-contracts",
|
"crates/aether-contracts",
|
||||||
"crates/aether-data",
|
"crates/aether-data",
|
||||||
"crates/aether-model-fetch",
|
"crates/aether-model-fetch",
|
||||||
|
"crates/aether-oauth",
|
||||||
"crates/aether-provider-transport",
|
"crates/aether-provider-transport",
|
||||||
"crates/aether-scheduler-core",
|
"crates/aether-scheduler-core",
|
||||||
"crates/aether-usage-runtime",
|
"crates/aether-usage-runtime",
|
||||||
@@ -40,6 +41,7 @@ aether-crypto = { path = "crates/aether-crypto" }
|
|||||||
aether-contracts = { path = "crates/aether-contracts" }
|
aether-contracts = { path = "crates/aether-contracts" }
|
||||||
aether-data = { path = "crates/aether-data" }
|
aether-data = { path = "crates/aether-data" }
|
||||||
aether-model-fetch = { path = "crates/aether-model-fetch" }
|
aether-model-fetch = { path = "crates/aether-model-fetch" }
|
||||||
|
aether-oauth = { path = "crates/aether-oauth" }
|
||||||
aether-provider-transport = { path = "crates/aether-provider-transport" }
|
aether-provider-transport = { path = "crates/aether-provider-transport" }
|
||||||
aether-scheduler-core = { path = "crates/aether-scheduler-core" }
|
aether-scheduler-core = { path = "crates/aether-scheduler-core" }
|
||||||
aether-usage-runtime = { path = "crates/aether-usage-runtime" }
|
aether-usage-runtime = { path = "crates/aether-usage-runtime" }
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ aether-data.workspace = true
|
|||||||
aether-data-contracts.workspace = true
|
aether-data-contracts.workspace = true
|
||||||
aether-http.workspace = true
|
aether-http.workspace = true
|
||||||
aether-model-fetch.workspace = true
|
aether-model-fetch.workspace = true
|
||||||
|
aether-oauth.workspace = true
|
||||||
aether-provider-transport.workspace = true
|
aether-provider-transport.workspace = true
|
||||||
aether-scheduler-core.workspace = true
|
aether-scheduler-core.workspace = true
|
||||||
aether-runtime.workspace = true
|
aether-runtime.workspace = true
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
pub(crate) use crate::handlers::admin::{
|
pub(crate) use crate::handlers::admin::{
|
||||||
admin_provider_ops_local_action_response, admin_provider_pool_config,
|
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,
|
maybe_build_local_admin_response, provider_oauth_runtime_endpoint_for_provider,
|
||||||
refresh_antigravity_provider_quota_locally, refresh_codex_provider_quota_locally,
|
refresh_antigravity_provider_quota_locally, refresh_codex_provider_quota_locally,
|
||||||
refresh_kiro_provider_quota_locally, AdminAppState, AdminRequestContext, AdminRouteRequest,
|
refresh_kiro_provider_quota_locally, refresh_provider_oauth_account_state_after_update,
|
||||||
AdminRouteResponse, AdminRouteResult, AdminStatsTimeRange, AdminStatsUsageFilter,
|
update_existing_provider_oauth_catalog_key, AdminAppState,
|
||||||
|
AdminGatewayProviderTransportSnapshot, AdminLocalOAuthRefreshError, AdminRequestContext,
|
||||||
|
AdminRouteRequest, AdminRouteResponse, AdminRouteResult, AdminStatsTimeRange,
|
||||||
|
AdminStatsUsageFilter,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::handlers::admin::{
|
use crate::handlers::admin::{
|
||||||
|
|||||||
@@ -4,7 +4,87 @@ pub(super) fn classify_oauth_route(
|
|||||||
method: &http::Method,
|
method: &http::Method,
|
||||||
normalized_path: &str,
|
normalized_path: &str,
|
||||||
) -> Option<ClassifiedRoute> {
|
) -> 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(
|
Some(classified(
|
||||||
"admin_proxy",
|
"admin_proxy",
|
||||||
"oauth_manage",
|
"oauth_manage",
|
||||||
|
|||||||
@@ -659,45 +659,73 @@ fn classifies_auth_routes_as_public_support_route() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn does_not_classify_oauth_public_providers_route() {
|
fn classifies_oauth_public_providers_route() {
|
||||||
let headers = headers(&[]);
|
let headers = headers(&[]);
|
||||||
let uri: Uri = "/api/oauth/providers".parse().expect("uri should parse");
|
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]
|
#[test]
|
||||||
fn does_not_classify_oauth_public_authorize_route() {
|
fn classifies_oauth_public_authorize_route() {
|
||||||
let headers = headers(&[]);
|
let headers = headers(&[]);
|
||||||
let uri: Uri = "/api/oauth/linuxdo/authorize?client_device_id=device-1"
|
let uri: Uri = "/api/oauth/linuxdo/authorize?client_device_id=device-1"
|
||||||
.parse()
|
.parse()
|
||||||
.expect("uri should 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]
|
#[test]
|
||||||
fn does_not_classify_oauth_user_bindable_providers_route() {
|
fn classifies_oauth_user_bindable_providers_route() {
|
||||||
let headers = headers(&[]);
|
let headers = headers(&[]);
|
||||||
let uri: Uri = "/api/user/oauth/bindable-providers"
|
let uri: Uri = "/api/user/oauth/bindable-providers"
|
||||||
.parse()
|
.parse()
|
||||||
.expect("uri should 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]
|
#[test]
|
||||||
fn does_not_classify_oauth_user_bind_token_route() {
|
fn classifies_oauth_user_bind_token_route() {
|
||||||
let headers = headers(&[]);
|
let headers = headers(&[]);
|
||||||
let uri: Uri = "/api/user/oauth/linuxdo/bind-token"
|
let uri: Uri = "/api/user/oauth/linuxdo/bind-token"
|
||||||
.parse()
|
.parse()
|
||||||
.expect("uri should 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]
|
#[test]
|
||||||
|
|||||||
@@ -34,14 +34,24 @@ pub(crate) struct AdminOAuthProviderUpsertRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn build_admin_oauth_supported_types_payload() -> Vec<serde_json::Value> {
|
pub(super) fn build_admin_oauth_supported_types_payload() -> Vec<serde_json::Value> {
|
||||||
vec![json!({
|
vec![
|
||||||
"provider_type": "linuxdo",
|
json!({
|
||||||
"display_name": "Linux Do",
|
"provider_type": "linuxdo",
|
||||||
"default_authorization_url": "https://connect.linux.do/oauth2/authorize",
|
"display_name": "Linux Do",
|
||||||
"default_token_url": "https://connect.linux.do/oauth2/token",
|
"default_authorization_url": "https://connect.linux.do/oauth2/authorize",
|
||||||
"default_userinfo_url": "https://connect.linux.do/api/user",
|
"default_token_url": "https://connect.linux.do/oauth2/token",
|
||||||
"default_scopes": [],
|
"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(
|
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 {
|
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") {
|
if provider_type.eq_ignore_ascii_case("linuxdo") {
|
||||||
Some(&["linux.do", "connect.linux.do", "connect.linuxdo.org"])
|
Some(&["linux.do", "connect.linux.do", "connect.linuxdo.org"])
|
||||||
} else {
|
} 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> {
|
fn validate_admin_oauth_frontend_callback_url(url: &str) -> Result<(), String> {
|
||||||
let parsed = Url::parse(url).map_err(|_| "frontend_callback_url 必须是绝对 URL".to_string())?;
|
let parsed = Url::parse(url).map_err(|_| "frontend_callback_url 必须是绝对 URL".to_string())?;
|
||||||
if !matches!(parsed.scheme(), "http" | "https") {
|
if !matches!(parsed.scheme(), "http" | "https") {
|
||||||
@@ -134,6 +168,17 @@ fn validate_admin_oauth_url_override(url: &str, allowed_domains: &[&str]) -> Res
|
|||||||
Ok(())
|
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(
|
pub(super) fn build_admin_oauth_upsert_record(
|
||||||
state: &AdminAppState<'_>,
|
state: &AdminAppState<'_>,
|
||||||
provider_type: &str,
|
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_frontend_callback_url(frontend_callback_url)?;
|
||||||
validate_admin_oauth_redirect_uri(redirect_uri)?;
|
validate_admin_oauth_redirect_uri(redirect_uri)?;
|
||||||
|
|
||||||
let allowed_domains = admin_oauth_allowed_domains(provider_type)
|
let is_custom_oidc = provider_type.eq_ignore_ascii_case("custom_oidc");
|
||||||
.ok_or_else(|| "不支持的 provider_type".to_string())?;
|
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 let Some(value) = payload.authorization_url_override.as_deref().map(str::trim) {
|
||||||
if !value.is_empty() {
|
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 let Some(value) = payload.token_url_override.as_deref().map(str::trim) {
|
||||||
if !value.is_empty() {
|
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 let Some(value) = payload.userinfo_url_override.as_deref().map(str::trim) {
|
||||||
if !value.is_empty() {
|
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)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,17 @@ pub(crate) use self::observability::{
|
|||||||
admin_stats_bad_request_response, maybe_build_local_admin_usage_response, parse_bounded_u32,
|
admin_stats_bad_request_response, maybe_build_local_admin_usage_response, parse_bounded_u32,
|
||||||
round_to, AdminStatsTimeRange, AdminStatsUsageFilter,
|
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::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::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::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::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::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::config::admin_provider_pool_config;
|
||||||
pub(crate) use self::provider::pool_admin::maybe_build_local_admin_pool_response;
|
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,
|
maybe_build_local_admin_provider_oauth_response, maybe_build_local_admin_providers_response,
|
||||||
};
|
};
|
||||||
pub(crate) use self::request::{
|
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;
|
pub(crate) use self::routes::maybe_build_local_admin_response;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -95,6 +95,19 @@ pub(super) async fn execute_admin_provider_oauth_refresh(
|
|||||||
response::oauth_refresh_failed_service_unavailable_response(source.to_string()),
|
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, .. }) => {
|
Err(AdminLocalOAuthRefreshError::InvalidResponse { message, .. }) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
trace_id = %trace_id,
|
trace_id = %trace_id,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
use super::super::errors::{
|
use super::super::errors::{
|
||||||
build_internal_control_error_response, normalize_provider_oauth_refresh_error_message,
|
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 crate::handlers::admin::request::{AdminAppState, AdminProviderOAuthTemplate};
|
||||||
use aether_contracts::ProxySnapshot;
|
use aether_contracts::ProxySnapshot;
|
||||||
|
use aether_oauth::provider::providers::GenericProviderOAuthAdapter;
|
||||||
|
use aether_oauth::provider::{ProviderOAuthService, ProviderOAuthTransportContext};
|
||||||
use axum::{body::Body, http, response::Response};
|
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 {
|
fn provider_oauth_transport_error_detail(prefix: &str, error: &str) -> String {
|
||||||
let error = error.trim();
|
let error = error.trim();
|
||||||
@@ -15,6 +16,51 @@ fn provider_oauth_transport_error_detail(prefix: &str, error: &str) -> String {
|
|||||||
format!("{prefix}: {error}")
|
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(
|
pub(crate) async fn exchange_admin_provider_oauth_code(
|
||||||
state: &AdminAppState<'_>,
|
state: &AdminAppState<'_>,
|
||||||
template: AdminProviderOAuthTemplate,
|
template: AdminProviderOAuthTemplate,
|
||||||
@@ -24,122 +70,25 @@ pub(crate) async fn exchange_admin_provider_oauth_code(
|
|||||||
proxy: Option<ProxySnapshot>,
|
proxy: Option<ProxySnapshot>,
|
||||||
) -> Result<serde_json::Value, Response<Body>> {
|
) -> Result<serde_json::Value, Response<Body>> {
|
||||||
let token_url = state.provider_oauth_token_url(template.provider_type, template.token_url);
|
let token_url = state.provider_oauth_token_url(template.provider_type, template.token_url);
|
||||||
let response = if template.provider_type == "claude_code" {
|
let service = provider_oauth_service_for_template(template, token_url)?;
|
||||||
let mut body = serde_json::Map::from_iter([
|
let ctx = provider_oauth_exchange_context(template.provider_type, proxy);
|
||||||
(
|
let executor = crate::oauth::GatewayOAuthHttpExecutor::new(*state);
|
||||||
"grant_type".to_string(),
|
let result = service
|
||||||
serde_json::Value::String("authorization_code".to_string()),
|
.exchange_code(&executor, &ctx, code, state_nonce, pkce_verifier)
|
||||||
),
|
.await
|
||||||
(
|
.map_err(|error| match error {
|
||||||
"client_id".to_string(),
|
aether_oauth::core::OAuthError::HttpStatus { .. } => {
|
||||||
serde_json::Value::String(template.client_id.to_string()),
|
build_internal_control_error_response(
|
||||||
),
|
http::StatusCode::BAD_REQUEST,
|
||||||
(
|
"token exchange 失败",
|
||||||
"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);
|
|
||||||
}
|
}
|
||||||
if let Some(verifier) = pkce_verifier {
|
error => build_internal_control_error_response(
|
||||||
form.append_pair("code_verifier", verifier);
|
http::StatusCode::BAD_REQUEST,
|
||||||
}
|
provider_oauth_transport_error_detail("token exchange 失败", &error.to_string()),
|
||||||
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"),
|
|
||||||
),
|
),
|
||||||
(
|
})?;
|
||||||
reqwest::header::ACCEPT,
|
token_payload_from_provider_oauth_result(result)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn exchange_admin_provider_oauth_refresh_token(
|
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>,
|
proxy: Option<ProxySnapshot>,
|
||||||
) -> Result<serde_json::Value, Response<Body>> {
|
) -> Result<serde_json::Value, Response<Body>> {
|
||||||
let token_url = state.provider_oauth_token_url(template.provider_type, template.token_url);
|
let token_url = state.provider_oauth_token_url(template.provider_type, template.token_url);
|
||||||
let scope = template.scopes.join(" ");
|
let service = provider_oauth_service_for_template(template, token_url)?;
|
||||||
let response = if template.provider_type == "claude_code" {
|
let ctx = provider_oauth_exchange_context(template.provider_type, proxy);
|
||||||
let mut body = serde_json::Map::from_iter([
|
let executor = crate::oauth::GatewayOAuthHttpExecutor::new(*state);
|
||||||
(
|
let input = aether_oauth::provider::ProviderOAuthImportInput {
|
||||||
"grant_type".to_string(),
|
provider_type: template.provider_type.to_string(),
|
||||||
serde_json::Value::String("refresh_token".to_string()),
|
name: None,
|
||||||
),
|
refresh_token: Some(refresh_token.to_string()),
|
||||||
(
|
raw_credentials: None,
|
||||||
"client_id".to_string(),
|
network: ctx.network.clone(),
|
||||||
serde_json::Value::String(template.client_id.to_string()),
|
};
|
||||||
),
|
let result = service
|
||||||
(
|
.import_credentials(&executor, &ctx, input)
|
||||||
"refresh_token".to_string(),
|
.await
|
||||||
serde_json::Value::String(refresh_token.to_string()),
|
.map_err(|error| match error {
|
||||||
),
|
aether_oauth::core::OAuthError::HttpStatus {
|
||||||
]);
|
status_code,
|
||||||
if !scope.trim().is_empty() {
|
body_excerpt,
|
||||||
body.insert("scope".to_string(), serde_json::Value::String(scope));
|
} => {
|
||||||
}
|
let reason = normalize_provider_oauth_refresh_error_message(
|
||||||
let headers = reqwest::header::HeaderMap::from_iter([
|
Some(status_code),
|
||||||
(
|
Some(&body_excerpt),
|
||||||
reqwest::header::CONTENT_TYPE,
|
);
|
||||||
reqwest::header::HeaderValue::from_static("application/json"),
|
build_internal_control_error_response(
|
||||||
),
|
http::StatusCode::BAD_REQUEST,
|
||||||
(
|
format!("Refresh Token 验证失败: {reason}"),
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
if !template.client_secret.trim().is_empty() {
|
error => build_internal_control_error_response(
|
||||||
form.append_pair("client_secret", template.client_secret);
|
http::StatusCode::BAD_REQUEST,
|
||||||
}
|
provider_oauth_transport_error_detail(
|
||||||
form.finish().into_bytes()
|
"Refresh Token 验证失败: token exchange 失败",
|
||||||
};
|
&error.to_string(),
|
||||||
let headers = reqwest::header::HeaderMap::from_iter([
|
),
|
||||||
(
|
|
||||||
reqwest::header::CONTENT_TYPE,
|
|
||||||
reqwest::header::HeaderValue::from_static("application/x-www-form-urlencoded"),
|
|
||||||
),
|
),
|
||||||
(
|
})?;
|
||||||
reqwest::header::ACCEPT,
|
token_payload_from_provider_oauth_result(result).map_err(|_| {
|
||||||
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| {
|
|
||||||
build_internal_control_error_response(
|
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,
|
http::StatusCode::BAD_REQUEST,
|
||||||
"token refresh 返回缺少 access_token",
|
"token refresh 返回缺少 access_token",
|
||||||
));
|
)
|
||||||
}
|
})
|
||||||
Ok(payload)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::handlers::admin::request::AdminProviderOAuthTemplate;
|
use crate::handlers::admin::request::AdminProviderOAuthTemplate;
|
||||||
|
use aether_oauth::provider::{ProviderOAuthService, ProviderOAuthTransportContext};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use url::form_urlencoded;
|
use url::form_urlencoded;
|
||||||
|
|
||||||
@@ -7,6 +8,48 @@ pub(crate) fn build_provider_oauth_start_response(
|
|||||||
nonce: &str,
|
nonce: &str,
|
||||||
code_challenge: Option<&str>,
|
code_challenge: Option<&str>,
|
||||||
) -> serde_json::Value {
|
) -> 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_uri(localhost)\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());
|
let mut serializer = form_urlencoded::Serializer::new(String::new());
|
||||||
serializer.append_pair("client_id", template.client_id);
|
serializer.append_pair("client_id", template.client_id);
|
||||||
serializer.append_pair("response_type", "code");
|
serializer.append_pair("response_type", "code");
|
||||||
@@ -25,10 +68,5 @@ pub(crate) fn build_provider_oauth_start_response(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
json!({
|
format!("{}?{}", template.authorize_url, serializer.finish())
|
||||||
"authorization_url": format!("{}?{}", template.authorize_url, serializer.finish()),
|
|
||||||
"redirect_uri": template.redirect_uri,
|
|
||||||
"provider_type": template.provider_type,
|
|
||||||
"instructions": "1) 打开 authorization_url 完成授权\n2) 授权后会跳转到 redirect_uri(localhost)\n3) 复制浏览器地址栏完整 URL,调用 complete 接口粘贴 callback_url",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,23 +37,24 @@ impl<'a> AdminAppState<'a> {
|
|||||||
encrypted_auth_config: Option<&str>,
|
encrypted_auth_config: Option<&str>,
|
||||||
expires_at_unix_secs: Option<u64>,
|
expires_at_unix_secs: Option<u64>,
|
||||||
) -> Result<bool, GatewayError> {
|
) -> Result<bool, GatewayError> {
|
||||||
self.app
|
crate::oauth::ProviderOAuthRepository::update_provider_catalog_key_oauth_credentials(
|
||||||
.update_provider_catalog_key_oauth_credentials(
|
self,
|
||||||
key_id,
|
key_id,
|
||||||
encrypted_api_key,
|
encrypted_api_key,
|
||||||
encrypted_auth_config,
|
encrypted_auth_config,
|
||||||
expires_at_unix_secs,
|
expires_at_unix_secs,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn clear_provider_catalog_key_oauth_invalid_marker(
|
pub(crate) async fn clear_provider_catalog_key_oauth_invalid_marker(
|
||||||
&self,
|
&self,
|
||||||
key_id: &str,
|
key_id: &str,
|
||||||
) -> Result<bool, GatewayError> {
|
) -> Result<bool, GatewayError> {
|
||||||
self.app
|
crate::oauth::ProviderOAuthRepository::clear_provider_catalog_key_oauth_invalid_marker(
|
||||||
.clear_provider_catalog_key_oauth_invalid_marker(key_id)
|
self, key_id,
|
||||||
.await
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn force_local_oauth_refresh_entry(
|
pub(crate) async fn force_local_oauth_refresh_entry(
|
||||||
@@ -61,7 +62,8 @@ impl<'a> AdminAppState<'a> {
|
|||||||
transport: &AdminGatewayProviderTransportSnapshot,
|
transport: &AdminGatewayProviderTransportSnapshot,
|
||||||
) -> Result<Option<crate::provider_transport::CachedOAuthEntry>, AdminLocalOAuthRefreshError>
|
) -> 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(
|
pub(crate) async fn save_provider_oauth_state(
|
||||||
@@ -425,23 +427,12 @@ impl<'a> AdminAppState<'a> {
|
|||||||
temporary_proxy_node_id: Option<&str>,
|
temporary_proxy_node_id: Option<&str>,
|
||||||
configured_proxies: &[Option<&serde_json::Value>],
|
configured_proxies: &[Option<&serde_json::Value>],
|
||||||
) -> Option<ProxySnapshot> {
|
) -> Option<ProxySnapshot> {
|
||||||
if let Some(snapshot) = self
|
crate::oauth::resolve_provider_oauth_operation_proxy_snapshot(
|
||||||
.resolve_admin_proxy_node_snapshot(temporary_proxy_node_id)
|
self,
|
||||||
.await
|
temporary_proxy_node_id,
|
||||||
{
|
configured_proxies,
|
||||||
return Some(snapshot);
|
)
|
||||||
}
|
.await
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn find_duplicate_provider_oauth_key(
|
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>,
|
Option<aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey>,
|
||||||
String,
|
String,
|
||||||
> {
|
> {
|
||||||
crate::handlers::admin::provider::oauth::duplicates::find_duplicate_provider_oauth_key(
|
crate::oauth::ProviderOAuthRepository::find_duplicate_provider_oauth_key(
|
||||||
self,
|
self,
|
||||||
provider_id,
|
provider_id,
|
||||||
auth_config,
|
auth_config,
|
||||||
@@ -476,7 +467,7 @@ impl<'a> AdminAppState<'a> {
|
|||||||
Option<aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey>,
|
Option<aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey>,
|
||||||
GatewayError,
|
GatewayError,
|
||||||
> {
|
> {
|
||||||
crate::handlers::admin::provider::oauth::provisioning::create_provider_oauth_catalog_key(
|
crate::oauth::ProviderOAuthRepository::create_provider_oauth_catalog_key(
|
||||||
self,
|
self,
|
||||||
provider_id,
|
provider_id,
|
||||||
provider_type,
|
provider_type,
|
||||||
@@ -503,7 +494,7 @@ impl<'a> AdminAppState<'a> {
|
|||||||
Option<aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey>,
|
Option<aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey>,
|
||||||
GatewayError,
|
GatewayError,
|
||||||
> {
|
> {
|
||||||
crate::handlers::admin::provider::oauth::provisioning::update_existing_provider_oauth_catalog_key(
|
crate::oauth::ProviderOAuthRepository::update_existing_provider_oauth_catalog_key(
|
||||||
self,
|
self,
|
||||||
existing_key,
|
existing_key,
|
||||||
provider_type,
|
provider_type,
|
||||||
@@ -522,7 +513,7 @@ impl<'a> AdminAppState<'a> {
|
|||||||
key_id: &str,
|
key_id: &str,
|
||||||
proxy_override: Option<&ProxySnapshot>,
|
proxy_override: Option<&ProxySnapshot>,
|
||||||
) -> Result<(bool, Option<String>), GatewayError> {
|
) -> 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,
|
self,
|
||||||
provider,
|
provider,
|
||||||
key_id,
|
key_id,
|
||||||
@@ -618,56 +609,31 @@ impl<'a> AdminAppState<'a> {
|
|||||||
body_bytes: Option<Vec<u8>>,
|
body_bytes: Option<Vec<u8>>,
|
||||||
proxy: Option<ProxySnapshot>,
|
proxy: Option<ProxySnapshot>,
|
||||||
) -> Result<AdminProviderOAuthHttpResponse, String> {
|
) -> Result<AdminProviderOAuthHttpResponse, String> {
|
||||||
let body = if let Some(json_body) = json_body {
|
let network = aether_oauth::network::OAuthNetworkContext::provider_operation(proxy);
|
||||||
RequestBody::from_json(json_body)
|
let request = aether_oauth::network::OAuthHttpRequest {
|
||||||
} 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 {
|
|
||||||
request_id: request_id.to_string(),
|
request_id: request_id.to_string(),
|
||||||
candidate_id: None,
|
method,
|
||||||
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(),
|
|
||||||
url: url.to_string(),
|
url: url.to_string(),
|
||||||
headers: admin_provider_oauth_execution_headers(headers),
|
headers: admin_provider_oauth_execution_headers(headers),
|
||||||
content_type: content_type
|
content_type: content_type
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.map(ToOwned::to_owned),
|
.map(ToOwned::to_owned),
|
||||||
content_encoding: None,
|
json_body,
|
||||||
body,
|
body_bytes,
|
||||||
stream: false,
|
network,
|
||||||
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()
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
let result = self
|
let response = aether_oauth::network::OAuthHttpExecutor::execute(
|
||||||
.execute_execution_runtime_sync_plan(None, &plan)
|
&crate::oauth::GatewayOAuthHttpExecutor::new(*self),
|
||||||
.await
|
request,
|
||||||
.map_err(admin_provider_oauth_gateway_error_message)?;
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
Ok(AdminProviderOAuthHttpResponse {
|
Ok(AdminProviderOAuthHttpResponse {
|
||||||
status: http::StatusCode::from_u16(result.status_code)
|
status: http::StatusCode::from_u16(response.status_code)
|
||||||
.unwrap_or(http::StatusCode::BAD_GATEWAY),
|
.unwrap_or(http::StatusCode::BAD_GATEWAY),
|
||||||
body_text: admin_provider_oauth_execution_body_text(&result),
|
body_text: response.body_text,
|
||||||
json_body: admin_provider_oauth_execution_json_body(&result),
|
json_body: response.json_body,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ mod support_dashboard;
|
|||||||
mod support_models;
|
mod support_models;
|
||||||
#[path = "support/monitoring.rs"]
|
#[path = "support/monitoring.rs"]
|
||||||
mod support_monitoring;
|
mod support_monitoring;
|
||||||
|
#[path = "support/oauth.rs"]
|
||||||
|
mod support_oauth;
|
||||||
#[path = "support/payment.rs"]
|
#[path = "support/payment.rs"]
|
||||||
mod support_payment;
|
mod support_payment;
|
||||||
#[path = "support/test_connection.rs"]
|
#[path = "support/test_connection.rs"]
|
||||||
@@ -56,13 +58,14 @@ use self::support_auth::auth_session::{
|
|||||||
};
|
};
|
||||||
use self::support_auth::{
|
use self::support_auth::{
|
||||||
build_auth_error_response, build_auth_json_response, build_auth_registration_settings_payload,
|
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_dashboard::maybe_build_local_dashboard_response;
|
||||||
use self::support_models::{
|
use self::support_models::{
|
||||||
build_models_auth_error_response, maybe_build_local_models_response, models_api_format,
|
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_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_payment::maybe_build_local_payment_callback_response;
|
||||||
use self::support_test_connection::maybe_build_local_test_connection_response;
|
use self::support_test_connection::maybe_build_local_test_connection_response;
|
||||||
use self::support_user_me::maybe_build_local_users_me_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;
|
.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") {
|
if decision.route_family.as_deref() == Some("dashboard") {
|
||||||
return Some(maybe_build_local_dashboard_response(state, request_context, headers).await);
|
return Some(maybe_build_local_dashboard_response(state, request_context, headers).await);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ pub(super) fn extract_cookie_value(headers: &http::HeaderMap, cookie_name: &str)
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn extract_client_device_id(
|
pub(crate) fn extract_client_device_id(
|
||||||
request_context: &GatewayPublicRequestContext,
|
request_context: &GatewayPublicRequestContext,
|
||||||
headers: &http::HeaderMap,
|
headers: &http::HeaderMap,
|
||||||
) -> Result<String, Response<Body>> {
|
) -> Result<String, Response<Body>> {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ fn base64url_decode(value: &str) -> Result<Vec<u8>, String> {
|
|||||||
.map_err(|_| "无效的Token".to_string())
|
.map_err(|_| "无效的Token".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn create_auth_token(
|
pub(crate) fn create_auth_token(
|
||||||
token_type: &str,
|
token_type: &str,
|
||||||
mut payload: serde_json::Map<String, serde_json::Value>,
|
mut payload: serde_json::Map<String, serde_json::Value>,
|
||||||
expires_at: chrono::DateTime<chrono::Utc>,
|
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,
|
token: &str,
|
||||||
expected_type: &str,
|
expected_type: &str,
|
||||||
) -> Result<serde_json::Map<String, serde_json::Value>, String> {
|
) -> 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,
|
state: &AppState,
|
||||||
headers: &http::HeaderMap,
|
headers: &http::HeaderMap,
|
||||||
client_device_id: String,
|
client_device_id: String,
|
||||||
|
|||||||
728
apps/aether-gateway/src/handlers/public/support/oauth.rs
Normal file
728
apps/aether-gateway/src/handlers/public/support/oauth.rs
Normal 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ mod log_ids;
|
|||||||
mod maintenance;
|
mod maintenance;
|
||||||
pub(crate) mod middleware;
|
pub(crate) mod middleware;
|
||||||
mod model_fetch;
|
mod model_fetch;
|
||||||
|
mod oauth;
|
||||||
mod orchestration;
|
mod orchestration;
|
||||||
mod provider_key_auth;
|
mod provider_key_auth;
|
||||||
pub(crate) use aether_provider_transport as provider_transport;
|
pub(crate) use aether_provider_transport as provider_transport;
|
||||||
|
|||||||
165
apps/aether-gateway/src/oauth/http_executor.rs
Normal file
165
apps/aether-gateway/src/oauth/http_executor.rs
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
803
apps/aether-gateway/src/oauth/identity_repo.rs
Normal file
803
apps/aether-gateway/src/oauth/identity_repo.rs
Normal 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())
|
||||||
|
}
|
||||||
21
apps/aether-gateway/src/oauth/mod.rs
Normal file
21
apps/aether-gateway/src/oauth/mod.rs
Normal 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,
|
||||||
|
};
|
||||||
122
apps/aether-gateway/src/oauth/provider_repo.rs
Normal file
122
apps/aether-gateway/src/oauth/provider_repo.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
44
apps/aether-gateway/src/oauth/proxy.rs
Normal file
44
apps/aether-gateway/src/oauth/proxy.rs
Normal 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
|
||||||
|
}
|
||||||
118
apps/aether-gateway/src/oauth/state_store.rs
Normal file
118
apps/aether-gateway/src/oauth/state_store.rs
Normal 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()
|
||||||
|
}
|
||||||
@@ -5675,9 +5675,11 @@ async fn gateway_handles_admin_oauth_supported_types_locally_with_trusted_admin_
|
|||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
let items = payload.as_array().expect("items should be array");
|
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]["provider_type"], "linuxdo");
|
||||||
assert_eq!(items[0]["display_name"], "Linux Do");
|
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);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
@@ -5802,6 +5804,152 @@ async fn gateway_upserts_admin_oauth_provider_locally_with_trusted_admin_princip
|
|||||||
upstream_handle.abort();
|
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]
|
#[tokio::test]
|
||||||
async fn gateway_deletes_admin_oauth_provider_locally_with_trusted_admin_principal() {
|
async fn gateway_deletes_admin_oauth_provider_locally_with_trusted_admin_principal() {
|
||||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use crate::tests::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 = Arc::new(Mutex::new(0usize));
|
||||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||||
let upstream = Router::new().route(
|
let upstream = Router::new().route(
|
||||||
@@ -27,10 +27,9 @@ async fn gateway_rejects_oauth_public_providers_as_local_not_found_without_hitti
|
|||||||
.await
|
.await
|
||||||
.expect("request should succeed");
|
.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");
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
assert_eq!(payload["error"]["type"], "http_error");
|
assert_eq!(payload["providers"].as_array().map(Vec::len), Some(0));
|
||||||
assert_eq!(payload["error"]["message"], "Route not found");
|
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
@@ -38,8 +37,42 @@ async fn gateway_rejects_oauth_public_providers_as_local_not_found_without_hitti
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 = Arc::new(Mutex::new(0usize));
|
||||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||||
let upstream = Router::new().route(
|
let upstream = Router::new().route(
|
||||||
@@ -63,10 +96,9 @@ async fn gateway_rejects_oauth_user_bindable_providers_as_local_not_found_withou
|
|||||||
.await
|
.await
|
||||||
.expect("request should succeed");
|
.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");
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
assert_eq!(payload["error"]["type"], "http_error");
|
assert_eq!(payload["detail"], "缺少用户凭证");
|
||||||
assert_eq!(payload["error"]["message"], "Route not found");
|
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
@@ -74,7 +106,7 @@ async fn gateway_rejects_oauth_user_bindable_providers_as_local_not_found_withou
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 = Arc::new(Mutex::new(0usize));
|
||||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||||
let upstream = Router::new().route(
|
let upstream = Router::new().route(
|
||||||
@@ -98,10 +130,9 @@ async fn gateway_rejects_oauth_user_bind_token_as_local_not_found_without_hittin
|
|||||||
.await
|
.await
|
||||||
.expect("request should succeed");
|
.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");
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
assert_eq!(payload["error"]["type"], "http_error");
|
assert_eq!(payload["detail"], "缺少用户凭证");
|
||||||
assert_eq!(payload["error"]["message"], "Route not found");
|
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
|
|||||||
23
crates/aether-oauth/Cargo.toml
Normal file
23
crates/aether-oauth/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "aether-oauth"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
description = "Shared OAuth abstractions for Aether identity and provider account flows"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
aether-contracts.workspace = true
|
||||||
|
async-trait.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
|
http.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
url.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio.workspace = true
|
||||||
38
crates/aether-oauth/src/core/error.rs
Normal file
38
crates/aether-oauth/src/core/error.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum OAuthError {
|
||||||
|
#[error("unsupported oauth provider: {0}")]
|
||||||
|
UnsupportedProvider(String),
|
||||||
|
#[error("invalid oauth request: {0}")]
|
||||||
|
InvalidRequest(String),
|
||||||
|
#[error("oauth state is invalid or expired")]
|
||||||
|
InvalidState,
|
||||||
|
#[error("oauth provider returned HTTP {status_code}: {body_excerpt}")]
|
||||||
|
HttpStatus {
|
||||||
|
status_code: u16,
|
||||||
|
body_excerpt: String,
|
||||||
|
},
|
||||||
|
#[error("oauth provider returned invalid response: {0}")]
|
||||||
|
InvalidResponse(String),
|
||||||
|
#[error("oauth transport failed: {0}")]
|
||||||
|
Transport(String),
|
||||||
|
#[error("oauth storage failed: {0}")]
|
||||||
|
Storage(String),
|
||||||
|
#[error("oauth encryption failed")]
|
||||||
|
EncryptionUnavailable,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OAuthError {
|
||||||
|
pub fn invalid_request(detail: impl Into<String>) -> Self {
|
||||||
|
Self::InvalidRequest(detail.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn invalid_response(detail: impl Into<String>) -> Self {
|
||||||
|
Self::InvalidResponse(detail.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn transport(detail: impl Into<String>) -> Self {
|
||||||
|
Self::Transport(detail.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
47
crates/aether-oauth/src/core/flow.rs
Normal file
47
crates/aether-oauth/src/core/flow.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct OAuthProviderMetadata {
|
||||||
|
pub provider_type: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub authorize_url: String,
|
||||||
|
pub token_url: String,
|
||||||
|
pub client_id: String,
|
||||||
|
pub client_secret: Option<String>,
|
||||||
|
pub scopes: Vec<String>,
|
||||||
|
pub redirect_uri: String,
|
||||||
|
pub use_pkce: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct OAuthAuthorizeRequest {
|
||||||
|
pub state: String,
|
||||||
|
pub code_challenge: Option<String>,
|
||||||
|
pub prompt: Option<String>,
|
||||||
|
pub login_hint: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct OAuthAuthorizeResponse {
|
||||||
|
pub authorize_url: String,
|
||||||
|
pub state: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub code_challenge: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct OAuthCallback {
|
||||||
|
pub code: String,
|
||||||
|
pub state: String,
|
||||||
|
pub scope: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||||
|
pub struct OAuthDeviceAuthorization {
|
||||||
|
pub device_code: String,
|
||||||
|
pub user_code: String,
|
||||||
|
pub verification_uri: String,
|
||||||
|
pub verification_uri_complete: String,
|
||||||
|
pub expires_in: u64,
|
||||||
|
pub interval: u64,
|
||||||
|
}
|
||||||
16
crates/aether-oauth/src/core/mod.rs
Normal file
16
crates/aether-oauth/src/core/mod.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
mod error;
|
||||||
|
mod flow;
|
||||||
|
mod pkce;
|
||||||
|
mod registry;
|
||||||
|
mod token;
|
||||||
|
|
||||||
|
pub use error::OAuthError;
|
||||||
|
pub use flow::{
|
||||||
|
OAuthAuthorizeRequest, OAuthAuthorizeResponse, OAuthCallback, OAuthDeviceAuthorization,
|
||||||
|
OAuthProviderMetadata,
|
||||||
|
};
|
||||||
|
pub use pkce::{
|
||||||
|
generate_oauth_nonce, generate_pkce_verifier, parse_oauth_callback_params, pkce_s256,
|
||||||
|
};
|
||||||
|
pub use registry::OAuthAdapterRegistry;
|
||||||
|
pub use token::{current_unix_secs, OAuthTokenSet};
|
||||||
86
crates/aether-oauth/src/core/pkce.rs
Normal file
86
crates/aether-oauth/src/core/pkce.rs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use url::{form_urlencoded, Url};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub fn generate_oauth_nonce() -> String {
|
||||||
|
format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_pkce_verifier() -> String {
|
||||||
|
format!(
|
||||||
|
"{}{}{}",
|
||||||
|
Uuid::new_v4().simple(),
|
||||||
|
Uuid::new_v4().simple(),
|
||||||
|
Uuid::new_v4().simple()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pkce_s256(verifier: &str) -> String {
|
||||||
|
let digest = Sha256::digest(verifier.as_bytes());
|
||||||
|
URL_SAFE_NO_PAD.encode(digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_oauth_callback_params(callback_url: &str) -> BTreeMap<String, String> {
|
||||||
|
let mut merged = BTreeMap::new();
|
||||||
|
let Ok(url) = Url::parse(callback_url.trim()) else {
|
||||||
|
return merged;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (key, value) in form_urlencoded::parse(url.query().unwrap_or_default().as_bytes()) {
|
||||||
|
merged.insert(key.into_owned(), value.into_owned());
|
||||||
|
}
|
||||||
|
if let Some(fragment) = url.fragment() {
|
||||||
|
for (key, value) in form_urlencoded::parse(fragment.trim_start_matches('#').as_bytes()) {
|
||||||
|
merged.insert(key.into_owned(), value.into_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(code) = merged.get("code").cloned() {
|
||||||
|
if let Some((code_part, state_part)) = code.split_once('#') {
|
||||||
|
merged.insert("code".to_string(), code_part.to_string());
|
||||||
|
if !merged.contains_key("state") && !state_part.is_empty() {
|
||||||
|
let normalized_state = state_part
|
||||||
|
.strip_prefix("state=")
|
||||||
|
.unwrap_or(state_part)
|
||||||
|
.trim();
|
||||||
|
if !normalized_state.is_empty() {
|
||||||
|
merged.insert("state".to_string(), normalized_state.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merged
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{parse_oauth_callback_params, pkce_s256};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_query_and_fragment_callback_params() {
|
||||||
|
let params = parse_oauth_callback_params(
|
||||||
|
"http://localhost/callback?code=query&state=old#code=fragment&scope=email",
|
||||||
|
);
|
||||||
|
assert_eq!(params.get("code").map(String::as_str), Some("fragment"));
|
||||||
|
assert_eq!(params.get("state").map(String::as_str), Some("old"));
|
||||||
|
assert_eq!(params.get("scope").map(String::as_str), Some("email"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_state_from_code_suffix() {
|
||||||
|
let params =
|
||||||
|
parse_oauth_callback_params("http://localhost/callback?code=abc%23state=state-1");
|
||||||
|
assert_eq!(params.get("code").map(String::as_str), Some("abc"));
|
||||||
|
assert_eq!(params.get("state").map(String::as_str), Some("state-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pkce_s256_is_url_safe() {
|
||||||
|
let value = pkce_s256("verifier");
|
||||||
|
assert!(!value.contains('+'));
|
||||||
|
assert!(!value.contains('/'));
|
||||||
|
assert!(!value.contains('='));
|
||||||
|
}
|
||||||
|
}
|
||||||
54
crates/aether-oauth/src/core/registry.rs
Normal file
54
crates/aether-oauth/src/core/registry.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fmt;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct OAuthAdapterRegistry<T: ?Sized> {
|
||||||
|
adapters: BTreeMap<String, Arc<T>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: ?Sized> fmt::Debug for OAuthAdapterRegistry<T> {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("OAuthAdapterRegistry")
|
||||||
|
.field("provider_types", &self.adapters.keys().collect::<Vec<_>>())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: ?Sized> Clone for OAuthAdapterRegistry<T> {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
adapters: self.adapters.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: ?Sized> Default for OAuthAdapterRegistry<T> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
adapters: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: ?Sized> OAuthAdapterRegistry<T> {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert(&mut self, provider_type: &str, adapter: Arc<T>) {
|
||||||
|
let key = provider_type.trim().to_ascii_lowercase();
|
||||||
|
if !key.is_empty() {
|
||||||
|
self.adapters.insert(key, adapter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, provider_type: &str) -> Option<Arc<T>> {
|
||||||
|
self.adapters
|
||||||
|
.get(provider_type.trim().to_ascii_lowercase().as_str())
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn provider_types(&self) -> impl Iterator<Item = &str> {
|
||||||
|
self.adapters.keys().map(String::as_str)
|
||||||
|
}
|
||||||
|
}
|
||||||
112
crates/aether-oauth/src/core/token.rs
Normal file
112
crates/aether-oauth/src/core/token.rs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
use serde_json::Value;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct OAuthTokenSet {
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: Option<String>,
|
||||||
|
pub token_type: Option<String>,
|
||||||
|
pub scope: Option<String>,
|
||||||
|
pub expires_at_unix_secs: Option<u64>,
|
||||||
|
pub raw_payload: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OAuthTokenSet {
|
||||||
|
pub fn from_token_payload(payload: Value) -> Option<Self> {
|
||||||
|
let access_token = non_empty_string(payload.get("access_token"))
|
||||||
|
.or_else(|| non_empty_string(payload.get("accessToken")))?;
|
||||||
|
let expires_at_unix_secs = json_u64(
|
||||||
|
payload
|
||||||
|
.get("expires_in")
|
||||||
|
.or_else(|| payload.get("expiresIn")),
|
||||||
|
)
|
||||||
|
.map(|expires_in| current_unix_secs().saturating_add(expires_in))
|
||||||
|
.or_else(|| {
|
||||||
|
json_u64(
|
||||||
|
payload
|
||||||
|
.get("expires_at")
|
||||||
|
.or_else(|| payload.get("expiresAt")),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
Some(Self {
|
||||||
|
access_token,
|
||||||
|
refresh_token: non_empty_string(
|
||||||
|
payload
|
||||||
|
.get("refresh_token")
|
||||||
|
.or_else(|| payload.get("refreshToken")),
|
||||||
|
),
|
||||||
|
token_type: non_empty_string(
|
||||||
|
payload
|
||||||
|
.get("token_type")
|
||||||
|
.or_else(|| payload.get("tokenType")),
|
||||||
|
),
|
||||||
|
scope: non_empty_string(payload.get("scope")),
|
||||||
|
expires_at_unix_secs,
|
||||||
|
raw_payload: Some(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bearer_header_value(&self) -> String {
|
||||||
|
format!("Bearer {}", self.access_token.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn requires_refresh(&self, skew_secs: u64) -> bool {
|
||||||
|
self.expires_at_unix_secs
|
||||||
|
.map(|expires_at| current_unix_secs() >= expires_at.saturating_sub(skew_secs))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rotated_refresh_token<'a>(&'a self, existing: Option<&'a str>) -> Option<&'a str> {
|
||||||
|
self.refresh_token
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.or_else(|| existing.map(str::trim).filter(|value| !value.is_empty()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_unix_secs() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.ok()
|
||||||
|
.map(|value| value.as_secs())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_empty_string(value: Option<&Value>) -> Option<String> {
|
||||||
|
value
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_u64(value: Option<&Value>) -> Option<u64> {
|
||||||
|
match value? {
|
||||||
|
Value::Number(number) => number.as_u64(),
|
||||||
|
Value::String(value) => value.trim().parse::<u64>().ok(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::OAuthTokenSet;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_token_payload_and_preserves_refresh_token() {
|
||||||
|
let token = OAuthTokenSet::from_token_payload(json!({
|
||||||
|
"access_token": "access",
|
||||||
|
"refresh_token": "refresh",
|
||||||
|
"expires_in": 3600
|
||||||
|
}))
|
||||||
|
.expect("token should parse");
|
||||||
|
|
||||||
|
assert_eq!(token.access_token, "access");
|
||||||
|
assert_eq!(token.refresh_token.as_deref(), Some("refresh"));
|
||||||
|
assert!(token.expires_at_unix_secs.is_some());
|
||||||
|
assert_eq!(token.bearer_header_value(), "Bearer access");
|
||||||
|
}
|
||||||
|
}
|
||||||
126
crates/aether-oauth/src/identity/adapter.rs
Normal file
126
crates/aether-oauth/src/identity/adapter.rs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
use crate::core::{OAuthAuthorizeResponse, OAuthError, OAuthTokenSet};
|
||||||
|
use crate::network::{OAuthHttpExecutor, OAuthNetworkContext};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct IdentityOAuthProviderConfig {
|
||||||
|
pub provider_type: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub authorization_url: String,
|
||||||
|
pub token_url: String,
|
||||||
|
pub userinfo_url: Option<String>,
|
||||||
|
pub client_id: String,
|
||||||
|
pub client_secret: Option<String>,
|
||||||
|
pub scopes: Vec<String>,
|
||||||
|
pub redirect_uri: String,
|
||||||
|
pub frontend_callback_url: String,
|
||||||
|
pub attribute_mapping: Option<Value>,
|
||||||
|
pub extra_config: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct IdentityOAuthStartContext {
|
||||||
|
pub state: String,
|
||||||
|
pub code_challenge: Option<String>,
|
||||||
|
pub network: OAuthNetworkContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct IdentityOAuthExchangeContext {
|
||||||
|
pub code: String,
|
||||||
|
pub state: String,
|
||||||
|
pub pkce_verifier: Option<String>,
|
||||||
|
pub network: OAuthNetworkContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ExternalIdentity {
|
||||||
|
pub provider_type: String,
|
||||||
|
pub subject: String,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub raw: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct IdentityClaims {
|
||||||
|
pub provider_type: String,
|
||||||
|
pub subject: String,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub raw: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait IdentityOAuthProvider: Send + Sync {
|
||||||
|
fn provider_type(&self) -> &'static str;
|
||||||
|
|
||||||
|
fn build_authorize_url(
|
||||||
|
&self,
|
||||||
|
config: &IdentityOAuthProviderConfig,
|
||||||
|
ctx: &IdentityOAuthStartContext,
|
||||||
|
) -> Result<OAuthAuthorizeResponse, OAuthError>;
|
||||||
|
|
||||||
|
async fn exchange_code(
|
||||||
|
&self,
|
||||||
|
executor: &dyn OAuthHttpExecutor,
|
||||||
|
config: &IdentityOAuthProviderConfig,
|
||||||
|
ctx: &IdentityOAuthExchangeContext,
|
||||||
|
) -> Result<OAuthTokenSet, OAuthError>;
|
||||||
|
|
||||||
|
async fn fetch_identity(
|
||||||
|
&self,
|
||||||
|
executor: &dyn OAuthHttpExecutor,
|
||||||
|
config: &IdentityOAuthProviderConfig,
|
||||||
|
tokens: &OAuthTokenSet,
|
||||||
|
network: OAuthNetworkContext,
|
||||||
|
) -> Result<ExternalIdentity, OAuthError>;
|
||||||
|
|
||||||
|
fn map_identity(
|
||||||
|
&self,
|
||||||
|
config: &IdentityOAuthProviderConfig,
|
||||||
|
identity: ExternalIdentity,
|
||||||
|
) -> Result<IdentityClaims, OAuthError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn mapped_string(
|
||||||
|
raw: &Value,
|
||||||
|
mapping: Option<&Value>,
|
||||||
|
logical_key: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
let mapped_key = mapping
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.and_then(|object| object.get(logical_key))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(logical_key);
|
||||||
|
find_string(raw, mapped_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn find_string(raw: &Value, key: &str) -> Option<String> {
|
||||||
|
let mut current = raw;
|
||||||
|
for segment in key.split('.') {
|
||||||
|
current = current.get(segment)?;
|
||||||
|
}
|
||||||
|
current
|
||||||
|
.as_str()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn form_headers() -> BTreeMap<String, String> {
|
||||||
|
BTreeMap::from([
|
||||||
|
(
|
||||||
|
"content-type".to_string(),
|
||||||
|
"application/x-www-form-urlencoded".to_string(),
|
||||||
|
),
|
||||||
|
("accept".to_string(), "application/json".to_string()),
|
||||||
|
])
|
||||||
|
}
|
||||||
12
crates/aether-oauth/src/identity/mod.rs
Normal file
12
crates/aether-oauth/src/identity/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
mod adapter;
|
||||||
|
pub mod providers;
|
||||||
|
mod service;
|
||||||
|
|
||||||
|
pub use adapter::{
|
||||||
|
ExternalIdentity, IdentityClaims, IdentityOAuthExchangeContext, IdentityOAuthProvider,
|
||||||
|
IdentityOAuthProviderConfig, IdentityOAuthStartContext,
|
||||||
|
};
|
||||||
|
pub use service::{
|
||||||
|
bind_oauth_identity, login_with_oauth, start_identity_oauth, BoundOAuthIdentity,
|
||||||
|
IdentityOAuthService, OAuthLoginOutcome,
|
||||||
|
};
|
||||||
167
crates/aether-oauth/src/identity/providers/custom_oidc.rs
Normal file
167
crates/aether-oauth/src/identity/providers/custom_oidc.rs
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
use super::super::adapter::{find_string, form_headers, mapped_string};
|
||||||
|
use crate::core::{OAuthAuthorizeResponse, OAuthError, OAuthTokenSet};
|
||||||
|
use crate::identity::{
|
||||||
|
ExternalIdentity, IdentityClaims, IdentityOAuthExchangeContext, IdentityOAuthProvider,
|
||||||
|
IdentityOAuthProviderConfig, IdentityOAuthStartContext,
|
||||||
|
};
|
||||||
|
use crate::network::{OAuthHttpExecutor, OAuthHttpRequest, OAuthNetworkContext};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use url::form_urlencoded;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct CustomOidcIdentityOAuthProvider;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl IdentityOAuthProvider for CustomOidcIdentityOAuthProvider {
|
||||||
|
fn provider_type(&self) -> &'static str {
|
||||||
|
"custom_oidc"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_authorize_url(
|
||||||
|
&self,
|
||||||
|
config: &IdentityOAuthProviderConfig,
|
||||||
|
ctx: &IdentityOAuthStartContext,
|
||||||
|
) -> Result<OAuthAuthorizeResponse, OAuthError> {
|
||||||
|
let mut url = url::Url::parse(&config.authorization_url)
|
||||||
|
.map_err(|_| OAuthError::invalid_request("authorization_url must be absolute"))?;
|
||||||
|
{
|
||||||
|
let mut query = url.query_pairs_mut();
|
||||||
|
query.append_pair("response_type", "code");
|
||||||
|
query.append_pair("client_id", &config.client_id);
|
||||||
|
query.append_pair("redirect_uri", &config.redirect_uri);
|
||||||
|
query.append_pair("state", &ctx.state);
|
||||||
|
if !config.scopes.is_empty() {
|
||||||
|
query.append_pair("scope", &config.scopes.join(" "));
|
||||||
|
}
|
||||||
|
if let Some(challenge) = ctx.code_challenge.as_deref() {
|
||||||
|
query.append_pair("code_challenge", challenge);
|
||||||
|
query.append_pair("code_challenge_method", "S256");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(OAuthAuthorizeResponse {
|
||||||
|
authorize_url: url.to_string(),
|
||||||
|
state: ctx.state.clone(),
|
||||||
|
code_challenge: ctx.code_challenge.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exchange_code(
|
||||||
|
&self,
|
||||||
|
executor: &dyn OAuthHttpExecutor,
|
||||||
|
config: &IdentityOAuthProviderConfig,
|
||||||
|
ctx: &IdentityOAuthExchangeContext,
|
||||||
|
) -> Result<OAuthTokenSet, OAuthError> {
|
||||||
|
let body_bytes = {
|
||||||
|
let mut form = form_urlencoded::Serializer::new(String::new());
|
||||||
|
form.append_pair("grant_type", "authorization_code");
|
||||||
|
form.append_pair("client_id", &config.client_id);
|
||||||
|
form.append_pair("redirect_uri", &config.redirect_uri);
|
||||||
|
form.append_pair("code", &ctx.code);
|
||||||
|
if let Some(secret) = config
|
||||||
|
.client_secret
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
{
|
||||||
|
form.append_pair("client_secret", secret);
|
||||||
|
}
|
||||||
|
if let Some(verifier) = ctx.pkce_verifier.as_deref() {
|
||||||
|
form.append_pair("code_verifier", verifier);
|
||||||
|
}
|
||||||
|
form.finish().into_bytes()
|
||||||
|
};
|
||||||
|
let response = executor
|
||||||
|
.execute(OAuthHttpRequest {
|
||||||
|
request_id: format!("identity-oauth:{}:exchange-code", config.provider_type),
|
||||||
|
method: reqwest::Method::POST,
|
||||||
|
url: config.token_url.clone(),
|
||||||
|
headers: form_headers(),
|
||||||
|
content_type: Some("application/x-www-form-urlencoded".to_string()),
|
||||||
|
json_body: None,
|
||||||
|
body_bytes: Some(body_bytes),
|
||||||
|
network: ctx.network.clone(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
if !(200..300).contains(&response.status_code) {
|
||||||
|
return Err(OAuthError::HttpStatus {
|
||||||
|
status_code: response.status_code,
|
||||||
|
body_excerpt: response.body_text.chars().take(500).collect(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let payload = response
|
||||||
|
.json_body
|
||||||
|
.or_else(|| serde_json::from_str(&response.body_text).ok())
|
||||||
|
.ok_or_else(|| OAuthError::invalid_response("token response is not json"))?;
|
||||||
|
OAuthTokenSet::from_token_payload(payload)
|
||||||
|
.ok_or_else(|| OAuthError::invalid_response("token response missing access_token"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_identity(
|
||||||
|
&self,
|
||||||
|
executor: &dyn OAuthHttpExecutor,
|
||||||
|
config: &IdentityOAuthProviderConfig,
|
||||||
|
tokens: &OAuthTokenSet,
|
||||||
|
network: OAuthNetworkContext,
|
||||||
|
) -> Result<ExternalIdentity, OAuthError> {
|
||||||
|
let userinfo_url = config
|
||||||
|
.userinfo_url
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| OAuthError::invalid_request("userinfo_url is required"))?;
|
||||||
|
let response = executor
|
||||||
|
.execute(OAuthHttpRequest {
|
||||||
|
request_id: format!("identity-oauth:{}:userinfo", config.provider_type),
|
||||||
|
method: reqwest::Method::GET,
|
||||||
|
url: userinfo_url.to_string(),
|
||||||
|
headers: std::collections::BTreeMap::from([
|
||||||
|
("authorization".to_string(), tokens.bearer_header_value()),
|
||||||
|
("accept".to_string(), "application/json".to_string()),
|
||||||
|
]),
|
||||||
|
content_type: None,
|
||||||
|
json_body: None,
|
||||||
|
body_bytes: None,
|
||||||
|
network,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
if !(200..300).contains(&response.status_code) {
|
||||||
|
return Err(OAuthError::HttpStatus {
|
||||||
|
status_code: response.status_code,
|
||||||
|
body_excerpt: response.body_text.chars().take(500).collect(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let raw = response
|
||||||
|
.json_body
|
||||||
|
.or_else(|| serde_json::from_str(&response.body_text).ok())
|
||||||
|
.ok_or_else(|| OAuthError::invalid_response("userinfo response is not json"))?;
|
||||||
|
let subject = mapped_string(&raw, config.attribute_mapping.as_ref(), "sub")
|
||||||
|
.or_else(|| find_string(&raw, "id"))
|
||||||
|
.ok_or_else(|| OAuthError::invalid_response("userinfo response missing subject"))?;
|
||||||
|
Ok(ExternalIdentity {
|
||||||
|
provider_type: config.provider_type.clone(),
|
||||||
|
subject,
|
||||||
|
email: mapped_string(&raw, config.attribute_mapping.as_ref(), "email"),
|
||||||
|
username: mapped_string(&raw, config.attribute_mapping.as_ref(), "username"),
|
||||||
|
display_name: mapped_string(&raw, config.attribute_mapping.as_ref(), "display_name")
|
||||||
|
.or_else(|| find_string(&raw, "name")),
|
||||||
|
avatar_url: mapped_string(&raw, config.attribute_mapping.as_ref(), "avatar_url"),
|
||||||
|
raw,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_identity(
|
||||||
|
&self,
|
||||||
|
config: &IdentityOAuthProviderConfig,
|
||||||
|
identity: ExternalIdentity,
|
||||||
|
) -> Result<IdentityClaims, OAuthError> {
|
||||||
|
Ok(IdentityClaims {
|
||||||
|
provider_type: config.provider_type.clone(),
|
||||||
|
subject: identity.subject,
|
||||||
|
email: identity.email,
|
||||||
|
username: identity.username,
|
||||||
|
display_name: identity.display_name,
|
||||||
|
raw: identity.raw,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
57
crates/aether-oauth/src/identity/providers/linuxdo.rs
Normal file
57
crates/aether-oauth/src/identity/providers/linuxdo.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
use super::custom_oidc::CustomOidcIdentityOAuthProvider;
|
||||||
|
use crate::core::{OAuthAuthorizeResponse, OAuthError, OAuthTokenSet};
|
||||||
|
use crate::identity::{
|
||||||
|
ExternalIdentity, IdentityClaims, IdentityOAuthExchangeContext, IdentityOAuthProvider,
|
||||||
|
IdentityOAuthProviderConfig, IdentityOAuthStartContext,
|
||||||
|
};
|
||||||
|
use crate::network::{OAuthHttpExecutor, OAuthNetworkContext};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct LinuxDoIdentityOAuthProvider {
|
||||||
|
inner: CustomOidcIdentityOAuthProvider,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl IdentityOAuthProvider for LinuxDoIdentityOAuthProvider {
|
||||||
|
fn provider_type(&self) -> &'static str {
|
||||||
|
"linuxdo"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_authorize_url(
|
||||||
|
&self,
|
||||||
|
config: &IdentityOAuthProviderConfig,
|
||||||
|
ctx: &IdentityOAuthStartContext,
|
||||||
|
) -> Result<OAuthAuthorizeResponse, OAuthError> {
|
||||||
|
self.inner.build_authorize_url(config, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exchange_code(
|
||||||
|
&self,
|
||||||
|
executor: &dyn OAuthHttpExecutor,
|
||||||
|
config: &IdentityOAuthProviderConfig,
|
||||||
|
ctx: &IdentityOAuthExchangeContext,
|
||||||
|
) -> Result<OAuthTokenSet, OAuthError> {
|
||||||
|
self.inner.exchange_code(executor, config, ctx).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_identity(
|
||||||
|
&self,
|
||||||
|
executor: &dyn OAuthHttpExecutor,
|
||||||
|
config: &IdentityOAuthProviderConfig,
|
||||||
|
tokens: &OAuthTokenSet,
|
||||||
|
network: OAuthNetworkContext,
|
||||||
|
) -> Result<ExternalIdentity, OAuthError> {
|
||||||
|
self.inner
|
||||||
|
.fetch_identity(executor, config, tokens, network)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_identity(
|
||||||
|
&self,
|
||||||
|
config: &IdentityOAuthProviderConfig,
|
||||||
|
identity: ExternalIdentity,
|
||||||
|
) -> Result<IdentityClaims, OAuthError> {
|
||||||
|
self.inner.map_identity(config, identity)
|
||||||
|
}
|
||||||
|
}
|
||||||
5
crates/aether-oauth/src/identity/providers/mod.rs
Normal file
5
crates/aether-oauth/src/identity/providers/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mod custom_oidc;
|
||||||
|
mod linuxdo;
|
||||||
|
|
||||||
|
pub use custom_oidc::CustomOidcIdentityOAuthProvider;
|
||||||
|
pub use linuxdo::LinuxDoIdentityOAuthProvider;
|
||||||
137
crates/aether-oauth/src/identity/service.rs
Normal file
137
crates/aether-oauth/src/identity/service.rs
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
use super::{
|
||||||
|
IdentityClaims, IdentityOAuthExchangeContext, IdentityOAuthProvider,
|
||||||
|
IdentityOAuthProviderConfig, IdentityOAuthStartContext,
|
||||||
|
};
|
||||||
|
use crate::core::{OAuthAdapterRegistry, OAuthAuthorizeResponse, OAuthError};
|
||||||
|
use crate::network::OAuthHttpExecutor;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct IdentityOAuthService {
|
||||||
|
registry: OAuthAdapterRegistry<dyn IdentityOAuthProvider>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct OAuthLoginOutcome {
|
||||||
|
pub claims: IdentityClaims,
|
||||||
|
pub is_new_external_identity: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct BoundOAuthIdentity {
|
||||||
|
pub claims: IdentityClaims,
|
||||||
|
pub replaced_existing_binding: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IdentityOAuthService {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_builtin_providers() -> Self {
|
||||||
|
use super::providers::{CustomOidcIdentityOAuthProvider, LinuxDoIdentityOAuthProvider};
|
||||||
|
|
||||||
|
Self::new()
|
||||||
|
.with_provider(Arc::new(LinuxDoIdentityOAuthProvider::default()))
|
||||||
|
.with_provider(Arc::new(CustomOidcIdentityOAuthProvider))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_provider(mut self, provider: Arc<dyn IdentityOAuthProvider>) -> Self {
|
||||||
|
self.registry.insert(provider.provider_type(), provider);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn provider(
|
||||||
|
&self,
|
||||||
|
provider_type: &str,
|
||||||
|
) -> Result<Arc<dyn IdentityOAuthProvider>, OAuthError> {
|
||||||
|
self.registry
|
||||||
|
.get(provider_type)
|
||||||
|
.ok_or_else(|| OAuthError::UnsupportedProvider(provider_type.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(
|
||||||
|
&self,
|
||||||
|
config: &IdentityOAuthProviderConfig,
|
||||||
|
ctx: &IdentityOAuthStartContext,
|
||||||
|
) -> Result<OAuthAuthorizeResponse, OAuthError> {
|
||||||
|
self.provider(&config.provider_type)?
|
||||||
|
.build_authorize_url(config, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login(
|
||||||
|
&self,
|
||||||
|
executor: &dyn OAuthHttpExecutor,
|
||||||
|
config: &IdentityOAuthProviderConfig,
|
||||||
|
ctx: &IdentityOAuthExchangeContext,
|
||||||
|
) -> Result<OAuthLoginOutcome, OAuthError> {
|
||||||
|
let provider = self.provider(&config.provider_type)?;
|
||||||
|
login_with_oauth(provider.as_ref(), executor, config, ctx).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn bind(
|
||||||
|
&self,
|
||||||
|
executor: &dyn OAuthHttpExecutor,
|
||||||
|
config: &IdentityOAuthProviderConfig,
|
||||||
|
ctx: &IdentityOAuthExchangeContext,
|
||||||
|
) -> Result<BoundOAuthIdentity, OAuthError> {
|
||||||
|
let provider = self.provider(&config.provider_type)?;
|
||||||
|
bind_oauth_identity(provider.as_ref(), executor, config, ctx).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_identity_oauth(
|
||||||
|
provider: &dyn IdentityOAuthProvider,
|
||||||
|
config: &IdentityOAuthProviderConfig,
|
||||||
|
ctx: &IdentityOAuthStartContext,
|
||||||
|
) -> Result<OAuthAuthorizeResponse, OAuthError> {
|
||||||
|
provider.build_authorize_url(config, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login_with_oauth(
|
||||||
|
provider: &dyn IdentityOAuthProvider,
|
||||||
|
executor: &dyn OAuthHttpExecutor,
|
||||||
|
config: &IdentityOAuthProviderConfig,
|
||||||
|
ctx: &IdentityOAuthExchangeContext,
|
||||||
|
) -> Result<OAuthLoginOutcome, OAuthError> {
|
||||||
|
let tokens = provider.exchange_code(executor, config, ctx).await?;
|
||||||
|
let identity = provider
|
||||||
|
.fetch_identity(executor, config, &tokens, ctx.network.clone())
|
||||||
|
.await?;
|
||||||
|
let claims = provider.map_identity(config, identity)?;
|
||||||
|
Ok(OAuthLoginOutcome {
|
||||||
|
claims,
|
||||||
|
is_new_external_identity: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn bind_oauth_identity(
|
||||||
|
provider: &dyn IdentityOAuthProvider,
|
||||||
|
executor: &dyn OAuthHttpExecutor,
|
||||||
|
config: &IdentityOAuthProviderConfig,
|
||||||
|
ctx: &IdentityOAuthExchangeContext,
|
||||||
|
) -> Result<BoundOAuthIdentity, OAuthError> {
|
||||||
|
let tokens = provider.exchange_code(executor, config, ctx).await?;
|
||||||
|
let identity = provider
|
||||||
|
.fetch_identity(executor, config, &tokens, ctx.network.clone())
|
||||||
|
.await?;
|
||||||
|
let claims = provider.map_identity(config, identity)?;
|
||||||
|
Ok(BoundOAuthIdentity {
|
||||||
|
claims,
|
||||||
|
replaced_existing_binding: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::IdentityOAuthService;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builtin_identity_service_registers_login_and_custom_oidc_providers() {
|
||||||
|
let service = IdentityOAuthService::with_builtin_providers();
|
||||||
|
|
||||||
|
assert!(service.provider("linuxdo").is_ok());
|
||||||
|
assert!(service.provider("custom_oidc").is_ok());
|
||||||
|
assert!(service.provider("missing").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
14
crates/aether-oauth/src/lib.rs
Normal file
14
crates/aether-oauth/src/lib.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
pub mod core;
|
||||||
|
pub mod identity;
|
||||||
|
pub mod network;
|
||||||
|
pub mod provider;
|
||||||
|
|
||||||
|
pub use core::{
|
||||||
|
current_unix_secs, generate_oauth_nonce, generate_pkce_verifier, parse_oauth_callback_params,
|
||||||
|
pkce_s256, OAuthAdapterRegistry, OAuthAuthorizeRequest, OAuthAuthorizeResponse, OAuthCallback,
|
||||||
|
OAuthError, OAuthProviderMetadata, OAuthTokenSet,
|
||||||
|
};
|
||||||
|
pub use network::{
|
||||||
|
NetworkRequirement, OAuthHttpExecutor, OAuthHttpRequest, OAuthHttpResponse,
|
||||||
|
OAuthNetworkContext, OAuthNetworkPolicy, OAuthTimeouts,
|
||||||
|
};
|
||||||
72
crates/aether-oauth/src/network/context.rs
Normal file
72
crates/aether-oauth/src/network/context.rs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
use aether_contracts::ProxySnapshot;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum OAuthNetworkPolicy {
|
||||||
|
DirectOnly,
|
||||||
|
DirectOrSystemProxy,
|
||||||
|
ProviderOperationProxy,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum NetworkRequirement {
|
||||||
|
Optional,
|
||||||
|
RequiredProxyNode,
|
||||||
|
RequiredConfiguredProxy,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct OAuthTimeouts {
|
||||||
|
pub connect_ms: u64,
|
||||||
|
pub read_ms: u64,
|
||||||
|
pub write_ms: u64,
|
||||||
|
pub total_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OAuthTimeouts {
|
||||||
|
pub const DIRECT_DEFAULT: Self = Self {
|
||||||
|
connect_ms: 30_000,
|
||||||
|
read_ms: 30_000,
|
||||||
|
write_ms: 30_000,
|
||||||
|
total_ms: 30_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const PROXY_DEFAULT: Self = Self {
|
||||||
|
connect_ms: 60_000,
|
||||||
|
read_ms: 60_000,
|
||||||
|
write_ms: 60_000,
|
||||||
|
total_ms: 60_000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct OAuthNetworkContext {
|
||||||
|
pub policy: OAuthNetworkPolicy,
|
||||||
|
pub requirement: NetworkRequirement,
|
||||||
|
pub proxy: Option<ProxySnapshot>,
|
||||||
|
pub timeouts: OAuthTimeouts,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OAuthNetworkContext {
|
||||||
|
pub fn direct_identity() -> Self {
|
||||||
|
Self {
|
||||||
|
policy: OAuthNetworkPolicy::DirectOrSystemProxy,
|
||||||
|
requirement: NetworkRequirement::Optional,
|
||||||
|
proxy: None,
|
||||||
|
timeouts: OAuthTimeouts::DIRECT_DEFAULT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn provider_operation(proxy: Option<ProxySnapshot>) -> Self {
|
||||||
|
let timeouts = if proxy.is_some() {
|
||||||
|
OAuthTimeouts::PROXY_DEFAULT
|
||||||
|
} else {
|
||||||
|
OAuthTimeouts::DIRECT_DEFAULT
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
policy: OAuthNetworkPolicy::ProviderOperationProxy,
|
||||||
|
requirement: NetworkRequirement::Optional,
|
||||||
|
proxy,
|
||||||
|
timeouts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
crates/aether-oauth/src/network/executor.rs
Normal file
74
crates/aether-oauth/src/network/executor.rs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
use crate::core::OAuthError;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use super::OAuthNetworkContext;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct OAuthHttpRequest {
|
||||||
|
pub request_id: String,
|
||||||
|
pub method: reqwest::Method,
|
||||||
|
pub url: String,
|
||||||
|
pub headers: BTreeMap<String, String>,
|
||||||
|
pub content_type: Option<String>,
|
||||||
|
pub json_body: Option<Value>,
|
||||||
|
pub body_bytes: Option<Vec<u8>>,
|
||||||
|
pub network: OAuthNetworkContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct OAuthHttpResponse {
|
||||||
|
pub status_code: u16,
|
||||||
|
pub body_text: String,
|
||||||
|
pub json_body: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait OAuthHttpExecutor: Send + Sync {
|
||||||
|
async fn execute(&self, request: OAuthHttpRequest) -> Result<OAuthHttpResponse, OAuthError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ReqwestOAuthHttpExecutor {
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReqwestOAuthHttpExecutor {
|
||||||
|
pub fn new(client: reqwest::Client) -> Self {
|
||||||
|
Self { client }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl OAuthHttpExecutor for ReqwestOAuthHttpExecutor {
|
||||||
|
async fn execute(&self, request: OAuthHttpRequest) -> Result<OAuthHttpResponse, OAuthError> {
|
||||||
|
let mut builder = self
|
||||||
|
.client
|
||||||
|
.request(request.method.clone(), request.url.as_str());
|
||||||
|
for (name, value) in &request.headers {
|
||||||
|
builder = builder.header(name, value);
|
||||||
|
}
|
||||||
|
if let Some(json_body) = request.json_body.as_ref() {
|
||||||
|
builder = builder.json(json_body);
|
||||||
|
} else if let Some(body_bytes) = request.body_bytes.as_ref() {
|
||||||
|
builder = builder.body(body_bytes.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = builder
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| OAuthError::transport(err.to_string()))?;
|
||||||
|
let status_code = response.status().as_u16();
|
||||||
|
let body_text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|err| OAuthError::transport(err.to_string()))?;
|
||||||
|
let json_body = serde_json::from_str::<Value>(&body_text).ok();
|
||||||
|
Ok(OAuthHttpResponse {
|
||||||
|
status_code,
|
||||||
|
body_text,
|
||||||
|
json_body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
7
crates/aether-oauth/src/network/mod.rs
Normal file
7
crates/aether-oauth/src/network/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
mod context;
|
||||||
|
mod executor;
|
||||||
|
|
||||||
|
pub use context::{NetworkRequirement, OAuthNetworkContext, OAuthNetworkPolicy, OAuthTimeouts};
|
||||||
|
pub use executor::{
|
||||||
|
OAuthHttpExecutor, OAuthHttpRequest, OAuthHttpResponse, ReqwestOAuthHttpExecutor,
|
||||||
|
};
|
||||||
96
crates/aether-oauth/src/provider/account.rs
Normal file
96
crates/aether-oauth/src/provider/account.rs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
use crate::core::OAuthTokenSet;
|
||||||
|
use crate::network::OAuthNetworkContext;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ProviderOAuthCapabilities {
|
||||||
|
pub supports_authorization_code: bool,
|
||||||
|
pub supports_refresh_token_import: bool,
|
||||||
|
pub supports_batch_import: bool,
|
||||||
|
pub supports_device_flow: bool,
|
||||||
|
pub supports_account_probe: bool,
|
||||||
|
pub rotates_refresh_token: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderOAuthCapabilities {
|
||||||
|
pub const GENERIC_AUTH_CODE: Self = Self {
|
||||||
|
supports_authorization_code: true,
|
||||||
|
supports_refresh_token_import: true,
|
||||||
|
supports_batch_import: true,
|
||||||
|
supports_device_flow: false,
|
||||||
|
supports_account_probe: false,
|
||||||
|
rotates_refresh_token: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ProviderOAuthTransportContext {
|
||||||
|
pub provider_id: String,
|
||||||
|
pub provider_type: String,
|
||||||
|
pub endpoint_id: Option<String>,
|
||||||
|
pub key_id: Option<String>,
|
||||||
|
pub auth_type: Option<String>,
|
||||||
|
pub decrypted_api_key: Option<String>,
|
||||||
|
pub decrypted_auth_config: Option<String>,
|
||||||
|
pub provider_config: Option<Value>,
|
||||||
|
pub endpoint_config: Option<Value>,
|
||||||
|
pub key_config: Option<Value>,
|
||||||
|
pub network: OAuthNetworkContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ProviderOAuthTokenSet {
|
||||||
|
pub token_set: OAuthTokenSet,
|
||||||
|
pub auth_config: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ProviderOAuthAccount {
|
||||||
|
pub provider_type: String,
|
||||||
|
pub access_token: String,
|
||||||
|
pub auth_config: Value,
|
||||||
|
pub expires_at_unix_secs: Option<u64>,
|
||||||
|
pub identity: BTreeMap<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderOAuthAccount {
|
||||||
|
pub fn request_bearer_auth(&self) -> ProviderOAuthRequestAuth {
|
||||||
|
ProviderOAuthRequestAuth::Header {
|
||||||
|
name: "authorization".to_string(),
|
||||||
|
value: format!("Bearer {}", self.access_token.trim()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum ProviderOAuthRequestAuth {
|
||||||
|
Header {
|
||||||
|
name: String,
|
||||||
|
value: String,
|
||||||
|
},
|
||||||
|
Kiro {
|
||||||
|
name: String,
|
||||||
|
value: String,
|
||||||
|
auth_config: Value,
|
||||||
|
machine_id: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ProviderOAuthImportInput {
|
||||||
|
pub provider_type: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub refresh_token: Option<String>,
|
||||||
|
pub raw_credentials: Option<Value>,
|
||||||
|
pub network: OAuthNetworkContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ProviderOAuthAccountState {
|
||||||
|
pub is_valid: bool,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub quota: Option<Value>,
|
||||||
|
pub invalid_reason: Option<String>,
|
||||||
|
pub raw: Option<Value>,
|
||||||
|
}
|
||||||
74
crates/aether-oauth/src/provider/adapter.rs
Normal file
74
crates/aether-oauth/src/provider/adapter.rs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
use super::{
|
||||||
|
ProviderOAuthAccount, ProviderOAuthAccountState, ProviderOAuthCapabilities,
|
||||||
|
ProviderOAuthImportInput, ProviderOAuthRequestAuth, ProviderOAuthTokenSet,
|
||||||
|
ProviderOAuthTransportContext,
|
||||||
|
};
|
||||||
|
use crate::core::{OAuthAuthorizeResponse, OAuthError};
|
||||||
|
use crate::network::OAuthHttpExecutor;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ProviderOAuthProbeResult {
|
||||||
|
pub state: ProviderOAuthAccountState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ProviderOAuthAdapter: Send + Sync {
|
||||||
|
fn provider_type(&self) -> &'static str;
|
||||||
|
|
||||||
|
fn capabilities(&self) -> ProviderOAuthCapabilities;
|
||||||
|
|
||||||
|
fn build_authorize_url(
|
||||||
|
&self,
|
||||||
|
_ctx: &ProviderOAuthTransportContext,
|
||||||
|
_state: &str,
|
||||||
|
_code_challenge: Option<&str>,
|
||||||
|
) -> Result<OAuthAuthorizeResponse, OAuthError> {
|
||||||
|
Err(OAuthError::UnsupportedProvider(
|
||||||
|
self.provider_type().to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exchange_code(
|
||||||
|
&self,
|
||||||
|
_executor: &dyn OAuthHttpExecutor,
|
||||||
|
_ctx: &ProviderOAuthTransportContext,
|
||||||
|
_code: &str,
|
||||||
|
_state: &str,
|
||||||
|
_pkce_verifier: Option<&str>,
|
||||||
|
) -> Result<ProviderOAuthTokenSet, OAuthError> {
|
||||||
|
Err(OAuthError::UnsupportedProvider(
|
||||||
|
self.provider_type().to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn import_credentials(
|
||||||
|
&self,
|
||||||
|
executor: &dyn OAuthHttpExecutor,
|
||||||
|
ctx: &ProviderOAuthTransportContext,
|
||||||
|
input: ProviderOAuthImportInput,
|
||||||
|
) -> Result<ProviderOAuthTokenSet, OAuthError>;
|
||||||
|
|
||||||
|
async fn refresh(
|
||||||
|
&self,
|
||||||
|
executor: &dyn OAuthHttpExecutor,
|
||||||
|
ctx: &ProviderOAuthTransportContext,
|
||||||
|
account: &ProviderOAuthAccount,
|
||||||
|
) -> Result<ProviderOAuthTokenSet, OAuthError>;
|
||||||
|
|
||||||
|
fn resolve_request_auth(
|
||||||
|
&self,
|
||||||
|
account: &ProviderOAuthAccount,
|
||||||
|
) -> Result<ProviderOAuthRequestAuth, OAuthError>;
|
||||||
|
|
||||||
|
fn account_fingerprint(&self, account: &ProviderOAuthAccount) -> Option<String>;
|
||||||
|
|
||||||
|
async fn probe_account_state(
|
||||||
|
&self,
|
||||||
|
_executor: &dyn OAuthHttpExecutor,
|
||||||
|
_ctx: &ProviderOAuthTransportContext,
|
||||||
|
_account: &ProviderOAuthAccount,
|
||||||
|
) -> Result<Option<ProviderOAuthProbeResult>, OAuthError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
12
crates/aether-oauth/src/provider/mod.rs
Normal file
12
crates/aether-oauth/src/provider/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
mod account;
|
||||||
|
mod adapter;
|
||||||
|
pub mod providers;
|
||||||
|
mod service;
|
||||||
|
|
||||||
|
pub use account::{
|
||||||
|
ProviderOAuthAccount, ProviderOAuthAccountState, ProviderOAuthCapabilities,
|
||||||
|
ProviderOAuthImportInput, ProviderOAuthRequestAuth, ProviderOAuthTokenSet,
|
||||||
|
ProviderOAuthTransportContext,
|
||||||
|
};
|
||||||
|
pub use adapter::{ProviderOAuthAdapter, ProviderOAuthProbeResult};
|
||||||
|
pub use service::ProviderOAuthService;
|
||||||
168
crates/aether-oauth/src/provider/providers/antigravity.rs
Normal file
168
crates/aether-oauth/src/provider/providers/antigravity.rs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
use super::generic::{
|
||||||
|
provider_account_state_from_metadata, template_for_provider_type, GenericProviderOAuthAdapter,
|
||||||
|
};
|
||||||
|
use crate::provider::ProviderOAuthAdapter;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AntigravityProviderOAuthAdapter {
|
||||||
|
inner: GenericProviderOAuthAdapter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AntigravityProviderOAuthAdapter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: GenericProviderOAuthAdapter::new(
|
||||||
|
template_for_provider_type("antigravity")
|
||||||
|
.expect("antigravity template should exist"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ProviderOAuthAdapter for AntigravityProviderOAuthAdapter {
|
||||||
|
fn provider_type(&self) -> &'static str {
|
||||||
|
self.inner.provider_type()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capabilities(&self) -> crate::provider::ProviderOAuthCapabilities {
|
||||||
|
crate::provider::ProviderOAuthCapabilities {
|
||||||
|
supports_account_probe: true,
|
||||||
|
..self.inner.capabilities()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_authorize_url(
|
||||||
|
&self,
|
||||||
|
ctx: &crate::provider::ProviderOAuthTransportContext,
|
||||||
|
state: &str,
|
||||||
|
code_challenge: Option<&str>,
|
||||||
|
) -> Result<crate::core::OAuthAuthorizeResponse, crate::core::OAuthError> {
|
||||||
|
self.inner.build_authorize_url(ctx, state, code_challenge)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exchange_code(
|
||||||
|
&self,
|
||||||
|
executor: &dyn crate::network::OAuthHttpExecutor,
|
||||||
|
ctx: &crate::provider::ProviderOAuthTransportContext,
|
||||||
|
code: &str,
|
||||||
|
state: &str,
|
||||||
|
pkce_verifier: Option<&str>,
|
||||||
|
) -> Result<crate::provider::ProviderOAuthTokenSet, crate::core::OAuthError> {
|
||||||
|
self.inner
|
||||||
|
.exchange_code(executor, ctx, code, state, pkce_verifier)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn import_credentials(
|
||||||
|
&self,
|
||||||
|
executor: &dyn crate::network::OAuthHttpExecutor,
|
||||||
|
ctx: &crate::provider::ProviderOAuthTransportContext,
|
||||||
|
input: crate::provider::ProviderOAuthImportInput,
|
||||||
|
) -> Result<crate::provider::ProviderOAuthTokenSet, crate::core::OAuthError> {
|
||||||
|
self.inner.import_credentials(executor, ctx, input).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh(
|
||||||
|
&self,
|
||||||
|
executor: &dyn crate::network::OAuthHttpExecutor,
|
||||||
|
ctx: &crate::provider::ProviderOAuthTransportContext,
|
||||||
|
account: &crate::provider::ProviderOAuthAccount,
|
||||||
|
) -> Result<crate::provider::ProviderOAuthTokenSet, crate::core::OAuthError> {
|
||||||
|
self.inner.refresh(executor, ctx, account).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_request_auth(
|
||||||
|
&self,
|
||||||
|
account: &crate::provider::ProviderOAuthAccount,
|
||||||
|
) -> Result<crate::provider::ProviderOAuthRequestAuth, crate::core::OAuthError> {
|
||||||
|
self.inner.resolve_request_auth(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn account_fingerprint(
|
||||||
|
&self,
|
||||||
|
account: &crate::provider::ProviderOAuthAccount,
|
||||||
|
) -> Option<String> {
|
||||||
|
self.inner.account_fingerprint(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn probe_account_state(
|
||||||
|
&self,
|
||||||
|
_executor: &dyn crate::network::OAuthHttpExecutor,
|
||||||
|
_ctx: &crate::provider::ProviderOAuthTransportContext,
|
||||||
|
account: &crate::provider::ProviderOAuthAccount,
|
||||||
|
) -> Result<Option<crate::provider::ProviderOAuthProbeResult>, crate::core::OAuthError> {
|
||||||
|
Ok(Some(provider_account_state_from_metadata(
|
||||||
|
"antigravity",
|
||||||
|
account,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::AntigravityProviderOAuthAdapter;
|
||||||
|
use crate::network::{OAuthHttpExecutor, OAuthHttpRequest, OAuthHttpResponse};
|
||||||
|
use crate::provider::{
|
||||||
|
ProviderOAuthAccount, ProviderOAuthAdapter, ProviderOAuthTransportContext,
|
||||||
|
};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
struct UnusedExecutor;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl OAuthHttpExecutor for UnusedExecutor {
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
_request: OAuthHttpRequest,
|
||||||
|
) -> Result<OAuthHttpResponse, crate::core::OAuthError> {
|
||||||
|
unreachable!("metadata probe should not execute network requests")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn antigravity_probe_marks_forbidden_metadata_invalid() {
|
||||||
|
let adapter = AntigravityProviderOAuthAdapter::default();
|
||||||
|
let ctx = ProviderOAuthTransportContext {
|
||||||
|
provider_id: String::new(),
|
||||||
|
provider_type: "antigravity".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: crate::network::OAuthNetworkContext::provider_operation(None),
|
||||||
|
};
|
||||||
|
let account = ProviderOAuthAccount {
|
||||||
|
provider_type: "antigravity".to_string(),
|
||||||
|
access_token: "access-token".to_string(),
|
||||||
|
auth_config: json!({
|
||||||
|
"email": "ag@example.com",
|
||||||
|
"antigravity": {
|
||||||
|
"is_forbidden": true,
|
||||||
|
"forbidden_reason": "project blocked"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
expires_at_unix_secs: Some(2000),
|
||||||
|
identity: BTreeMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let probe = adapter
|
||||||
|
.probe_account_state(&UnusedExecutor, &ctx, &account)
|
||||||
|
.await
|
||||||
|
.expect("probe should succeed")
|
||||||
|
.expect("probe should return state");
|
||||||
|
|
||||||
|
assert!(!probe.state.is_valid);
|
||||||
|
assert_eq!(probe.state.email.as_deref(), Some("ag@example.com"));
|
||||||
|
assert_eq!(
|
||||||
|
probe.state.invalid_reason.as_deref(),
|
||||||
|
Some("project blocked")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
201
crates/aether-oauth/src/provider/providers/codex.rs
Normal file
201
crates/aether-oauth/src/provider/providers/codex.rs
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
use super::generic::{
|
||||||
|
provider_account_state_from_metadata, template_for_provider_type, GenericProviderOAuthAdapter,
|
||||||
|
};
|
||||||
|
use crate::provider::ProviderOAuthAdapter;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CodexProviderOAuthAdapter {
|
||||||
|
inner: GenericProviderOAuthAdapter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CodexProviderOAuthAdapter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: GenericProviderOAuthAdapter::new(
|
||||||
|
template_for_provider_type("codex").expect("codex template should exist"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ProviderOAuthAdapter for CodexProviderOAuthAdapter {
|
||||||
|
fn provider_type(&self) -> &'static str {
|
||||||
|
self.inner.provider_type()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capabilities(&self) -> crate::provider::ProviderOAuthCapabilities {
|
||||||
|
crate::provider::ProviderOAuthCapabilities {
|
||||||
|
supports_account_probe: true,
|
||||||
|
..self.inner.capabilities()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_authorize_url(
|
||||||
|
&self,
|
||||||
|
ctx: &crate::provider::ProviderOAuthTransportContext,
|
||||||
|
state: &str,
|
||||||
|
code_challenge: Option<&str>,
|
||||||
|
) -> Result<crate::core::OAuthAuthorizeResponse, crate::core::OAuthError> {
|
||||||
|
let mut response = self.inner.build_authorize_url(ctx, state, code_challenge)?;
|
||||||
|
let mut url = url::Url::parse(&response.authorize_url)
|
||||||
|
.map_err(|_| crate::core::OAuthError::invalid_response("invalid authorize_url"))?;
|
||||||
|
{
|
||||||
|
let mut query = url.query_pairs_mut();
|
||||||
|
query.append_pair("prompt", "login");
|
||||||
|
query.append_pair("id_token_add_organizations", "true");
|
||||||
|
query.append_pair("codex_cli_simplified_flow", "true");
|
||||||
|
}
|
||||||
|
response.authorize_url = url.to_string();
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exchange_code(
|
||||||
|
&self,
|
||||||
|
executor: &dyn crate::network::OAuthHttpExecutor,
|
||||||
|
ctx: &crate::provider::ProviderOAuthTransportContext,
|
||||||
|
code: &str,
|
||||||
|
state: &str,
|
||||||
|
pkce_verifier: Option<&str>,
|
||||||
|
) -> Result<crate::provider::ProviderOAuthTokenSet, crate::core::OAuthError> {
|
||||||
|
self.inner
|
||||||
|
.exchange_code(executor, ctx, code, state, pkce_verifier)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn import_credentials(
|
||||||
|
&self,
|
||||||
|
executor: &dyn crate::network::OAuthHttpExecutor,
|
||||||
|
ctx: &crate::provider::ProviderOAuthTransportContext,
|
||||||
|
input: crate::provider::ProviderOAuthImportInput,
|
||||||
|
) -> Result<crate::provider::ProviderOAuthTokenSet, crate::core::OAuthError> {
|
||||||
|
self.inner.import_credentials(executor, ctx, input).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh(
|
||||||
|
&self,
|
||||||
|
executor: &dyn crate::network::OAuthHttpExecutor,
|
||||||
|
ctx: &crate::provider::ProviderOAuthTransportContext,
|
||||||
|
account: &crate::provider::ProviderOAuthAccount,
|
||||||
|
) -> Result<crate::provider::ProviderOAuthTokenSet, crate::core::OAuthError> {
|
||||||
|
self.inner.refresh(executor, ctx, account).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_request_auth(
|
||||||
|
&self,
|
||||||
|
account: &crate::provider::ProviderOAuthAccount,
|
||||||
|
) -> Result<crate::provider::ProviderOAuthRequestAuth, crate::core::OAuthError> {
|
||||||
|
self.inner.resolve_request_auth(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn account_fingerprint(
|
||||||
|
&self,
|
||||||
|
account: &crate::provider::ProviderOAuthAccount,
|
||||||
|
) -> Option<String> {
|
||||||
|
self.inner.account_fingerprint(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn probe_account_state(
|
||||||
|
&self,
|
||||||
|
_executor: &dyn crate::network::OAuthHttpExecutor,
|
||||||
|
_ctx: &crate::provider::ProviderOAuthTransportContext,
|
||||||
|
account: &crate::provider::ProviderOAuthAccount,
|
||||||
|
) -> Result<Option<crate::provider::ProviderOAuthProbeResult>, crate::core::OAuthError> {
|
||||||
|
Ok(Some(provider_account_state_from_metadata("codex", account)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::CodexProviderOAuthAdapter;
|
||||||
|
use crate::network::{OAuthHttpExecutor, OAuthHttpRequest, OAuthHttpResponse};
|
||||||
|
use crate::provider::{
|
||||||
|
ProviderOAuthAccount, ProviderOAuthAdapter, ProviderOAuthTransportContext,
|
||||||
|
};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
struct UnusedExecutor;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl OAuthHttpExecutor for UnusedExecutor {
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
_request: OAuthHttpRequest,
|
||||||
|
) -> Result<OAuthHttpResponse, crate::core::OAuthError> {
|
||||||
|
unreachable!("metadata probe should not execute network requests")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codex_authorize_url_includes_cli_flow_hints() {
|
||||||
|
let adapter = CodexProviderOAuthAdapter::default();
|
||||||
|
let ctx = ProviderOAuthTransportContext {
|
||||||
|
provider_id: String::new(),
|
||||||
|
provider_type: "codex".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: crate::network::OAuthNetworkContext::provider_operation(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = adapter
|
||||||
|
.build_authorize_url(&ctx, "state-1", Some("challenge-1"))
|
||||||
|
.expect("authorize url should build");
|
||||||
|
|
||||||
|
assert!(response.authorize_url.contains("prompt=login"));
|
||||||
|
assert!(response
|
||||||
|
.authorize_url
|
||||||
|
.contains("id_token_add_organizations=true"));
|
||||||
|
assert!(response
|
||||||
|
.authorize_url
|
||||||
|
.contains("codex_cli_simplified_flow=true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn codex_probe_reports_metadata_quota_and_email() {
|
||||||
|
let adapter = CodexProviderOAuthAdapter::default();
|
||||||
|
let ctx = ProviderOAuthTransportContext {
|
||||||
|
provider_id: String::new(),
|
||||||
|
provider_type: "codex".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: crate::network::OAuthNetworkContext::provider_operation(None),
|
||||||
|
};
|
||||||
|
let account = ProviderOAuthAccount {
|
||||||
|
provider_type: "codex".to_string(),
|
||||||
|
access_token: "access-token".to_string(),
|
||||||
|
auth_config: json!({
|
||||||
|
"email": "alice@example.com",
|
||||||
|
"codex": {
|
||||||
|
"remaining_percent": 42,
|
||||||
|
"updated_at": 1000
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
expires_at_unix_secs: Some(2000),
|
||||||
|
identity: BTreeMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let probe = adapter
|
||||||
|
.probe_account_state(&UnusedExecutor, &ctx, &account)
|
||||||
|
.await
|
||||||
|
.expect("probe should succeed")
|
||||||
|
.expect("probe should return state");
|
||||||
|
|
||||||
|
assert!(probe.state.is_valid);
|
||||||
|
assert_eq!(probe.state.email.as_deref(), Some("alice@example.com"));
|
||||||
|
assert_eq!(probe.state.quota.as_ref().unwrap()["remaining_percent"], 42);
|
||||||
|
}
|
||||||
|
}
|
||||||
687
crates/aether-oauth/src/provider/providers/generic.rs
Normal file
687
crates/aether-oauth/src/provider/providers/generic.rs
Normal file
@@ -0,0 +1,687 @@
|
|||||||
|
use crate::core::{current_unix_secs, OAuthAuthorizeResponse, OAuthError, OAuthTokenSet};
|
||||||
|
use crate::network::{OAuthHttpExecutor, OAuthHttpRequest};
|
||||||
|
use crate::provider::ProviderOAuthAdapter;
|
||||||
|
use crate::provider::{
|
||||||
|
ProviderOAuthAccount, ProviderOAuthAccountState, ProviderOAuthCapabilities,
|
||||||
|
ProviderOAuthImportInput, ProviderOAuthProbeResult, ProviderOAuthRequestAuth,
|
||||||
|
ProviderOAuthTokenSet, ProviderOAuthTransportContext,
|
||||||
|
};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use url::form_urlencoded;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct GenericProviderOAuthTemplate {
|
||||||
|
pub provider_type: &'static str,
|
||||||
|
pub display_name: &'static str,
|
||||||
|
pub authorize_url: &'static str,
|
||||||
|
pub token_url: &'static str,
|
||||||
|
pub client_id: &'static str,
|
||||||
|
pub client_secret: &'static str,
|
||||||
|
pub scopes: &'static [&'static str],
|
||||||
|
pub redirect_uri: &'static str,
|
||||||
|
pub use_pkce: bool,
|
||||||
|
pub uses_json_payload: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const GENERIC_PROVIDER_OAUTH_TEMPLATES: &[GenericProviderOAuthTemplate] = &[
|
||||||
|
GenericProviderOAuthTemplate {
|
||||||
|
provider_type: "claude_code",
|
||||||
|
display_name: "ClaudeCode",
|
||||||
|
authorize_url: "https://claude.ai/oauth/authorize",
|
||||||
|
token_url: "https://console.anthropic.com/v1/oauth/token",
|
||||||
|
client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
||||||
|
client_secret: "",
|
||||||
|
scopes: &["org:create_api_key", "user:profile", "user:inference"],
|
||||||
|
redirect_uri: "http://localhost:54545/callback",
|
||||||
|
use_pkce: true,
|
||||||
|
uses_json_payload: true,
|
||||||
|
},
|
||||||
|
GenericProviderOAuthTemplate {
|
||||||
|
provider_type: "codex",
|
||||||
|
display_name: "Codex",
|
||||||
|
authorize_url: "https://auth.openai.com/oauth/authorize",
|
||||||
|
token_url: "https://auth.openai.com/oauth/token",
|
||||||
|
client_id: "app_EMoamEEZ73f0CkXaXp7hrann",
|
||||||
|
client_secret: "",
|
||||||
|
scopes: &["openid", "email", "profile", "offline_access"],
|
||||||
|
redirect_uri: "http://localhost:1455/auth/callback",
|
||||||
|
use_pkce: true,
|
||||||
|
uses_json_payload: false,
|
||||||
|
},
|
||||||
|
GenericProviderOAuthTemplate {
|
||||||
|
provider_type: "gemini_cli",
|
||||||
|
display_name: "GeminiCli",
|
||||||
|
authorize_url: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||||
|
token_url: "https://oauth2.googleapis.com/token",
|
||||||
|
client_id: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
|
||||||
|
client_secret: "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",
|
||||||
|
scopes: &[
|
||||||
|
"https://www.googleapis.com/auth/cloud-platform",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.email",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.profile",
|
||||||
|
],
|
||||||
|
redirect_uri: "http://localhost:8085/oauth2callback",
|
||||||
|
use_pkce: false,
|
||||||
|
uses_json_payload: false,
|
||||||
|
},
|
||||||
|
GenericProviderOAuthTemplate {
|
||||||
|
provider_type: "antigravity",
|
||||||
|
display_name: "Antigravity",
|
||||||
|
authorize_url: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||||
|
token_url: "https://oauth2.googleapis.com/token",
|
||||||
|
client_id: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
|
||||||
|
client_secret: "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",
|
||||||
|
scopes: &[
|
||||||
|
"https://www.googleapis.com/auth/cloud-platform",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.email",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.profile",
|
||||||
|
"https://www.googleapis.com/auth/cclog",
|
||||||
|
"https://www.googleapis.com/auth/experimentsandconfigs",
|
||||||
|
],
|
||||||
|
redirect_uri: "http://localhost:51121/oauth2callback",
|
||||||
|
use_pkce: true,
|
||||||
|
uses_json_payload: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GenericProviderOAuthAdapter {
|
||||||
|
template: GenericProviderOAuthTemplate,
|
||||||
|
token_url_override: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GenericProviderOAuthAdapter {
|
||||||
|
pub fn new(template: GenericProviderOAuthTemplate) -> Self {
|
||||||
|
Self {
|
||||||
|
template,
|
||||||
|
token_url_override: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn for_provider_type(provider_type: &str) -> Option<Self> {
|
||||||
|
template_for_provider_type(provider_type).map(Self::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_token_url_override(mut self, token_url: impl Into<String>) -> Self {
|
||||||
|
self.token_url_override = Some(token_url.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_token_url_for_tests(self, token_url: impl Into<String>) -> Self {
|
||||||
|
self.with_token_url_override(token_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_url(&self) -> String {
|
||||||
|
self.token_url_override
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| self.template.token_url.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exchange_grant(
|
||||||
|
&self,
|
||||||
|
executor: &dyn OAuthHttpExecutor,
|
||||||
|
ctx: &ProviderOAuthTransportContext,
|
||||||
|
grant_type: &str,
|
||||||
|
code_or_refresh_token: &str,
|
||||||
|
state: Option<&str>,
|
||||||
|
pkce_verifier: Option<&str>,
|
||||||
|
) -> Result<ProviderOAuthTokenSet, OAuthError> {
|
||||||
|
let scope = (!self.template.scopes.is_empty()).then(|| self.template.scopes.join(" "));
|
||||||
|
let request_id = match grant_type {
|
||||||
|
"authorization_code" => "provider-oauth:exchange-code".to_string(),
|
||||||
|
"refresh_token" => "provider-oauth:refresh-token".to_string(),
|
||||||
|
_ => format!(
|
||||||
|
"provider-oauth:{}:{grant_type}",
|
||||||
|
self.template.provider_type
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let response = if self.template.uses_json_payload {
|
||||||
|
let mut body = serde_json::Map::from_iter([
|
||||||
|
(
|
||||||
|
"grant_type".to_string(),
|
||||||
|
Value::String(grant_type.to_string()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"client_id".to_string(),
|
||||||
|
Value::String(self.template.client_id.to_string()),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
if grant_type == "authorization_code" {
|
||||||
|
body.insert(
|
||||||
|
"code".to_string(),
|
||||||
|
Value::String(code_or_refresh_token.to_string()),
|
||||||
|
);
|
||||||
|
body.insert(
|
||||||
|
"redirect_uri".to_string(),
|
||||||
|
Value::String(self.template.redirect_uri.to_string()),
|
||||||
|
);
|
||||||
|
if let Some(state) = state {
|
||||||
|
body.insert("state".to_string(), Value::String(state.to_string()));
|
||||||
|
}
|
||||||
|
if let Some(verifier) = pkce_verifier {
|
||||||
|
body.insert(
|
||||||
|
"code_verifier".to_string(),
|
||||||
|
Value::String(verifier.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
body.insert(
|
||||||
|
"refresh_token".to_string(),
|
||||||
|
Value::String(code_or_refresh_token.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(scope) = scope.as_ref() {
|
||||||
|
body.insert("scope".to_string(), Value::String(scope.clone()));
|
||||||
|
}
|
||||||
|
executor
|
||||||
|
.execute(OAuthHttpRequest {
|
||||||
|
request_id: request_id.clone(),
|
||||||
|
method: reqwest::Method::POST,
|
||||||
|
url: self.token_url(),
|
||||||
|
headers: json_headers(),
|
||||||
|
content_type: Some("application/json".to_string()),
|
||||||
|
json_body: Some(Value::Object(body)),
|
||||||
|
body_bytes: None,
|
||||||
|
network: ctx.network.clone(),
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
let form_body = {
|
||||||
|
let mut form = form_urlencoded::Serializer::new(String::new());
|
||||||
|
form.append_pair("grant_type", grant_type);
|
||||||
|
form.append_pair("client_id", self.template.client_id);
|
||||||
|
if grant_type == "authorization_code" {
|
||||||
|
form.append_pair("redirect_uri", self.template.redirect_uri);
|
||||||
|
form.append_pair("code", code_or_refresh_token);
|
||||||
|
if let Some(verifier) = pkce_verifier {
|
||||||
|
form.append_pair("code_verifier", verifier);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
form.append_pair("refresh_token", code_or_refresh_token);
|
||||||
|
}
|
||||||
|
if let Some(scope) = scope.as_ref() {
|
||||||
|
form.append_pair("scope", scope);
|
||||||
|
}
|
||||||
|
if !self.template.client_secret.trim().is_empty() {
|
||||||
|
form.append_pair("client_secret", self.template.client_secret);
|
||||||
|
}
|
||||||
|
form.finish().into_bytes()
|
||||||
|
};
|
||||||
|
executor
|
||||||
|
.execute(OAuthHttpRequest {
|
||||||
|
request_id,
|
||||||
|
method: reqwest::Method::POST,
|
||||||
|
url: self.token_url(),
|
||||||
|
headers: form_headers(),
|
||||||
|
content_type: Some("application/x-www-form-urlencoded".to_string()),
|
||||||
|
json_body: None,
|
||||||
|
body_bytes: Some(form_body),
|
||||||
|
network: ctx.network.clone(),
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
if !(200..300).contains(&response.status_code) {
|
||||||
|
return Err(OAuthError::HttpStatus {
|
||||||
|
status_code: response.status_code,
|
||||||
|
body_excerpt: truncate_body(&response.body_text),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let payload = response
|
||||||
|
.json_body
|
||||||
|
.or_else(|| serde_json::from_str::<Value>(&response.body_text).ok())
|
||||||
|
.ok_or_else(|| OAuthError::invalid_response("token response is not json"))?;
|
||||||
|
self.token_set_from_payload(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_set_from_payload(&self, payload: Value) -> Result<ProviderOAuthTokenSet, OAuthError> {
|
||||||
|
let token_set = OAuthTokenSet::from_token_payload(payload.clone())
|
||||||
|
.ok_or_else(|| OAuthError::invalid_response("token response missing access_token"))?;
|
||||||
|
let mut auth_config = serde_json::Map::new();
|
||||||
|
auth_config.insert(
|
||||||
|
"provider_type".to_string(),
|
||||||
|
json!(self.template.provider_type),
|
||||||
|
);
|
||||||
|
auth_config.insert("updated_at".to_string(), json!(current_unix_secs()));
|
||||||
|
if let Some(token_type) = token_set.token_type.as_ref() {
|
||||||
|
auth_config.insert("token_type".to_string(), json!(token_type));
|
||||||
|
}
|
||||||
|
if let Some(refresh_token) = token_set.refresh_token.as_ref() {
|
||||||
|
auth_config.insert("refresh_token".to_string(), json!(refresh_token));
|
||||||
|
}
|
||||||
|
if let Some(expires_at) = token_set.expires_at_unix_secs {
|
||||||
|
auth_config.insert("expires_at".to_string(), json!(expires_at));
|
||||||
|
}
|
||||||
|
if let Some(scope) = token_set.scope.as_ref() {
|
||||||
|
auth_config.insert("scope".to_string(), json!(scope));
|
||||||
|
}
|
||||||
|
enrich_generic_identity(self.template.provider_type, &mut auth_config, &payload);
|
||||||
|
Ok(ProviderOAuthTokenSet {
|
||||||
|
token_set,
|
||||||
|
auth_config: Value::Object(auth_config),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ProviderOAuthAdapter for GenericProviderOAuthAdapter {
|
||||||
|
fn provider_type(&self) -> &'static str {
|
||||||
|
self.template.provider_type
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capabilities(&self) -> ProviderOAuthCapabilities {
|
||||||
|
ProviderOAuthCapabilities::GENERIC_AUTH_CODE
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_authorize_url(
|
||||||
|
&self,
|
||||||
|
_ctx: &ProviderOAuthTransportContext,
|
||||||
|
state: &str,
|
||||||
|
code_challenge: Option<&str>,
|
||||||
|
) -> Result<OAuthAuthorizeResponse, OAuthError> {
|
||||||
|
let mut url = url::Url::parse(self.template.authorize_url)
|
||||||
|
.map_err(|_| OAuthError::invalid_request("authorize_url must be absolute"))?;
|
||||||
|
{
|
||||||
|
let mut query = url.query_pairs_mut();
|
||||||
|
query.append_pair("response_type", "code");
|
||||||
|
query.append_pair("client_id", self.template.client_id);
|
||||||
|
query.append_pair("redirect_uri", self.template.redirect_uri);
|
||||||
|
query.append_pair("state", state);
|
||||||
|
if !self.template.scopes.is_empty() {
|
||||||
|
query.append_pair("scope", &self.template.scopes.join(" "));
|
||||||
|
}
|
||||||
|
if let Some(challenge) = code_challenge {
|
||||||
|
query.append_pair("code_challenge", challenge);
|
||||||
|
query.append_pair("code_challenge_method", "S256");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(OAuthAuthorizeResponse {
|
||||||
|
authorize_url: url.to_string(),
|
||||||
|
state: state.to_string(),
|
||||||
|
code_challenge: code_challenge.map(ToOwned::to_owned),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exchange_code(
|
||||||
|
&self,
|
||||||
|
executor: &dyn OAuthHttpExecutor,
|
||||||
|
ctx: &ProviderOAuthTransportContext,
|
||||||
|
code: &str,
|
||||||
|
state: &str,
|
||||||
|
pkce_verifier: Option<&str>,
|
||||||
|
) -> Result<ProviderOAuthTokenSet, OAuthError> {
|
||||||
|
self.exchange_grant(
|
||||||
|
executor,
|
||||||
|
ctx,
|
||||||
|
"authorization_code",
|
||||||
|
code,
|
||||||
|
Some(state),
|
||||||
|
pkce_verifier,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn import_credentials(
|
||||||
|
&self,
|
||||||
|
executor: &dyn OAuthHttpExecutor,
|
||||||
|
ctx: &ProviderOAuthTransportContext,
|
||||||
|
input: ProviderOAuthImportInput,
|
||||||
|
) -> Result<ProviderOAuthTokenSet, OAuthError> {
|
||||||
|
let refresh_token = input
|
||||||
|
.refresh_token
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| OAuthError::invalid_request("refresh_token is required"))?;
|
||||||
|
self.exchange_grant(executor, ctx, "refresh_token", refresh_token, None, None)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh(
|
||||||
|
&self,
|
||||||
|
executor: &dyn OAuthHttpExecutor,
|
||||||
|
ctx: &ProviderOAuthTransportContext,
|
||||||
|
account: &ProviderOAuthAccount,
|
||||||
|
) -> Result<ProviderOAuthTokenSet, OAuthError> {
|
||||||
|
let refresh_token = account
|
||||||
|
.auth_config
|
||||||
|
.get("refresh_token")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| OAuthError::invalid_request("auth_config missing refresh_token"))?;
|
||||||
|
let mut refreshed = self
|
||||||
|
.exchange_grant(executor, ctx, "refresh_token", refresh_token, None, None)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Refresh responses often omit stable account metadata, and some providers
|
||||||
|
// do not rotate refresh_token on every refresh. Preserve the stored config
|
||||||
|
// as the base while letting the fresh token payload win.
|
||||||
|
if let Some(existing) = account.auth_config.as_object() {
|
||||||
|
let mut merged = existing.clone();
|
||||||
|
if let Some(updated) = refreshed.auth_config.as_object() {
|
||||||
|
for (key, value) in updated {
|
||||||
|
merged.insert(key.clone(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if refreshed.token_set.refresh_token.is_none() {
|
||||||
|
refreshed.token_set.refresh_token = Some(refresh_token.to_string());
|
||||||
|
merged.insert("refresh_token".to_string(), json!(refresh_token));
|
||||||
|
}
|
||||||
|
refreshed.auth_config = Value::Object(merged);
|
||||||
|
}
|
||||||
|
Ok(refreshed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_request_auth(
|
||||||
|
&self,
|
||||||
|
account: &ProviderOAuthAccount,
|
||||||
|
) -> Result<ProviderOAuthRequestAuth, OAuthError> {
|
||||||
|
Ok(account.request_bearer_auth())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn account_fingerprint(&self, account: &ProviderOAuthAccount) -> Option<String> {
|
||||||
|
let refresh_token = account
|
||||||
|
.auth_config
|
||||||
|
.get("refresh_token")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.or(Some(account.access_token.as_str()))?;
|
||||||
|
Some(secret_fingerprint(refresh_token))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn template_for_provider_type(provider_type: &str) -> Option<GenericProviderOAuthTemplate> {
|
||||||
|
let normalized = provider_type.trim();
|
||||||
|
GENERIC_PROVIDER_OAUTH_TEMPLATES
|
||||||
|
.iter()
|
||||||
|
.find(|template| normalized.eq_ignore_ascii_case(template.provider_type))
|
||||||
|
.copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn form_headers() -> BTreeMap<String, String> {
|
||||||
|
BTreeMap::from([
|
||||||
|
(
|
||||||
|
"content-type".to_string(),
|
||||||
|
"application/x-www-form-urlencoded".to_string(),
|
||||||
|
),
|
||||||
|
("accept".to_string(), "application/json".to_string()),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_headers() -> BTreeMap<String, String> {
|
||||||
|
BTreeMap::from([
|
||||||
|
("content-type".to_string(), "application/json".to_string()),
|
||||||
|
("accept".to_string(), "application/json".to_string()),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_body(body: &str) -> String {
|
||||||
|
let body = body.trim();
|
||||||
|
if body.is_empty() {
|
||||||
|
"-".to_string()
|
||||||
|
} else {
|
||||||
|
body.chars().take(500).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn secret_fingerprint(value: &str) -> String {
|
||||||
|
let digest = Sha256::digest(value.as_bytes());
|
||||||
|
let mut fingerprint = String::with_capacity(16);
|
||||||
|
for byte in digest.iter().take(8) {
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
let _ = write!(&mut fingerprint, "{byte:02x}");
|
||||||
|
}
|
||||||
|
fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enrich_generic_identity(
|
||||||
|
provider_type: &str,
|
||||||
|
auth_config: &mut serde_json::Map<String, Value>,
|
||||||
|
token_payload: &Value,
|
||||||
|
) {
|
||||||
|
if let Some(object) = token_payload.as_object() {
|
||||||
|
for field in [
|
||||||
|
"email",
|
||||||
|
"account_id",
|
||||||
|
"account_user_id",
|
||||||
|
"plan_type",
|
||||||
|
"user_id",
|
||||||
|
"account_name",
|
||||||
|
] {
|
||||||
|
if !auth_config.contains_key(field) {
|
||||||
|
if let Some(value) = object.get(field).cloned() {
|
||||||
|
auth_config.insert(field.to_string(), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !provider_type.eq_ignore_ascii_case("codex") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(access_token) = token_payload
|
||||||
|
.get("access_token")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.or_else(|| token_payload.get("id_token").and_then(Value::as_str))
|
||||||
|
{
|
||||||
|
if let Some(claims) = decode_jwt_claims(access_token) {
|
||||||
|
for field in ["email", "sub"] {
|
||||||
|
if let Some(value) = claims.get(field).cloned() {
|
||||||
|
let target = if field == "sub" { "user_id" } else { field };
|
||||||
|
auth_config.entry(target.to_string()).or_insert(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(auth) = claims
|
||||||
|
.get("https://api.openai.com/auth")
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
{
|
||||||
|
for (source, target) in [
|
||||||
|
("chatgpt_account_id", "account_id"),
|
||||||
|
("chatgpt_account_user_id", "account_user_id"),
|
||||||
|
("chatgpt_plan_type", "plan_type"),
|
||||||
|
("chatgpt_user_id", "user_id"),
|
||||||
|
] {
|
||||||
|
if let Some(value) = auth.get(source).cloned() {
|
||||||
|
auth_config.entry(target.to_string()).or_insert(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(value) = auth.get("organizations").cloned() {
|
||||||
|
auth_config
|
||||||
|
.entry("organizations".to_string())
|
||||||
|
.or_insert(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn provider_account_state_from_metadata(
|
||||||
|
metadata_key: &str,
|
||||||
|
account: &ProviderOAuthAccount,
|
||||||
|
) -> ProviderOAuthProbeResult {
|
||||||
|
let metadata = account
|
||||||
|
.identity
|
||||||
|
.get(metadata_key)
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| account.auth_config.get(metadata_key).cloned());
|
||||||
|
let email = string_field(&account.auth_config, "email")
|
||||||
|
.or_else(|| account.identity.get("email").and_then(value_to_string))
|
||||||
|
.or_else(|| {
|
||||||
|
metadata
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| string_field(value, "email"))
|
||||||
|
});
|
||||||
|
let invalid_reason = string_field(&account.auth_config, "oauth_invalid_reason")
|
||||||
|
.or_else(|| string_field(&account.auth_config, "invalid_reason"))
|
||||||
|
.or_else(|| metadata.as_ref().and_then(metadata_invalid_reason));
|
||||||
|
let raw = json!({
|
||||||
|
"auth_config": account.auth_config,
|
||||||
|
"identity": account.identity,
|
||||||
|
});
|
||||||
|
ProviderOAuthProbeResult {
|
||||||
|
state: ProviderOAuthAccountState {
|
||||||
|
is_valid: !account.access_token.trim().is_empty() && invalid_reason.is_none(),
|
||||||
|
email,
|
||||||
|
quota: metadata,
|
||||||
|
invalid_reason,
|
||||||
|
raw: Some(raw),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata_invalid_reason(value: &Value) -> Option<String> {
|
||||||
|
if value
|
||||||
|
.get("is_forbidden")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return string_field(value, "forbidden_reason")
|
||||||
|
.or_else(|| string_field(value, "message"))
|
||||||
|
.or_else(|| Some("account_forbidden".to_string()));
|
||||||
|
}
|
||||||
|
if value
|
||||||
|
.get("account_disabled")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return string_field(value, "message")
|
||||||
|
.or_else(|| string_field(value, "reason"))
|
||||||
|
.or_else(|| Some("account_disabled".to_string()));
|
||||||
|
}
|
||||||
|
string_field(value, "invalid_reason").or_else(|| string_field(value, "reason"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_field(value: &Value, key: &str) -> Option<String> {
|
||||||
|
value.get(key).and_then(value_to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_to_string(value: &Value) -> Option<String> {
|
||||||
|
value
|
||||||
|
.as_str()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_jwt_claims(token: &str) -> Option<serde_json::Map<String, Value>> {
|
||||||
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||||
|
let payload = token.split('.').nth(1)?;
|
||||||
|
let bytes = URL_SAFE_NO_PAD.decode(payload.as_bytes()).ok()?;
|
||||||
|
serde_json::from_slice::<Value>(&bytes)
|
||||||
|
.ok()?
|
||||||
|
.as_object()
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{template_for_provider_type, GenericProviderOAuthAdapter};
|
||||||
|
use crate::network::{OAuthHttpExecutor, OAuthHttpRequest, OAuthHttpResponse};
|
||||||
|
use crate::provider::ProviderOAuthAdapter;
|
||||||
|
use crate::provider::{ProviderOAuthAccount, ProviderOAuthTransportContext};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_generic_provider_templates() {
|
||||||
|
assert!(template_for_provider_type("codex").is_some());
|
||||||
|
assert!(template_for_provider_type("claude_code").is_some());
|
||||||
|
assert!(template_for_provider_type("kiro").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generic_adapter_exposes_provider_type() {
|
||||||
|
let adapter = GenericProviderOAuthAdapter::for_provider_type("codex")
|
||||||
|
.expect("codex template should exist");
|
||||||
|
assert_eq!(adapter.provider_type(), "codex");
|
||||||
|
assert!(adapter.capabilities().supports_refresh_token_import);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct StaticExecutor {
|
||||||
|
seen_request: Arc<Mutex<Option<OAuthHttpRequest>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl OAuthHttpExecutor for StaticExecutor {
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
request: OAuthHttpRequest,
|
||||||
|
) -> Result<OAuthHttpResponse, crate::core::OAuthError> {
|
||||||
|
*self.seen_request.lock().expect("mutex should lock") = Some(request);
|
||||||
|
Ok(OAuthHttpResponse {
|
||||||
|
status_code: 200,
|
||||||
|
body_text: json!({
|
||||||
|
"access_token": "new-access-token",
|
||||||
|
"expires_in": 3600
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
json_body: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn refresh_preserves_existing_metadata_when_refresh_token_is_not_rotated() {
|
||||||
|
let seen_request = Arc::new(Mutex::new(None));
|
||||||
|
let executor = StaticExecutor {
|
||||||
|
seen_request: Arc::clone(&seen_request),
|
||||||
|
};
|
||||||
|
let adapter = GenericProviderOAuthAdapter::for_provider_type("codex")
|
||||||
|
.expect("codex adapter should exist")
|
||||||
|
.with_token_url_override("https://auth.example.test/token");
|
||||||
|
let ctx = ProviderOAuthTransportContext {
|
||||||
|
provider_id: "provider-1".to_string(),
|
||||||
|
provider_type: "codex".to_string(),
|
||||||
|
endpoint_id: None,
|
||||||
|
key_id: Some("key-1".to_string()),
|
||||||
|
auth_type: Some("oauth".to_string()),
|
||||||
|
decrypted_api_key: None,
|
||||||
|
decrypted_auth_config: None,
|
||||||
|
provider_config: None,
|
||||||
|
endpoint_config: None,
|
||||||
|
key_config: None,
|
||||||
|
network: crate::network::OAuthNetworkContext::provider_operation(None),
|
||||||
|
};
|
||||||
|
let account = ProviderOAuthAccount {
|
||||||
|
provider_type: "codex".to_string(),
|
||||||
|
access_token: "old-access-token".to_string(),
|
||||||
|
auth_config: json!({
|
||||||
|
"provider_type": "codex",
|
||||||
|
"refresh_token": "old-refresh-token",
|
||||||
|
"email": "alice@example.com",
|
||||||
|
"account_id": "acct-123",
|
||||||
|
"updated_at": 1
|
||||||
|
}),
|
||||||
|
expires_at_unix_secs: Some(1),
|
||||||
|
identity: BTreeMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let refreshed = adapter
|
||||||
|
.refresh(&executor, &ctx, &account)
|
||||||
|
.await
|
||||||
|
.expect("refresh should succeed");
|
||||||
|
|
||||||
|
assert_eq!(refreshed.token_set.access_token, "new-access-token");
|
||||||
|
assert_eq!(
|
||||||
|
refreshed.token_set.refresh_token.as_deref(),
|
||||||
|
Some("old-refresh-token")
|
||||||
|
);
|
||||||
|
assert_eq!(refreshed.auth_config["email"], "alice@example.com");
|
||||||
|
assert_eq!(refreshed.auth_config["account_id"], "acct-123");
|
||||||
|
assert_eq!(refreshed.auth_config["refresh_token"], "old-refresh-token");
|
||||||
|
|
||||||
|
let seen = seen_request
|
||||||
|
.lock()
|
||||||
|
.expect("mutex should lock")
|
||||||
|
.clone()
|
||||||
|
.expect("request should be captured");
|
||||||
|
let form = String::from_utf8(seen.body_bytes.expect("form body should exist"))
|
||||||
|
.expect("form body should be utf8");
|
||||||
|
assert!(form.contains("grant_type=refresh_token"));
|
||||||
|
assert!(form.contains("refresh_token=old-refresh-token"));
|
||||||
|
}
|
||||||
|
}
|
||||||
576
crates/aether-oauth/src/provider/providers/kiro.rs
Normal file
576
crates/aether-oauth/src/provider/providers/kiro.rs
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
use crate::core::{current_unix_secs, OAuthError};
|
||||||
|
use crate::network::{OAuthHttpExecutor, OAuthHttpRequest};
|
||||||
|
use crate::provider::ProviderOAuthAdapter;
|
||||||
|
use crate::provider::{
|
||||||
|
ProviderOAuthAccount, ProviderOAuthCapabilities, ProviderOAuthImportInput,
|
||||||
|
ProviderOAuthRequestAuth, ProviderOAuthTokenSet, ProviderOAuthTransportContext,
|
||||||
|
};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
pub const KIRO_PROVIDER_TYPE: &str = "kiro";
|
||||||
|
const IDC_AMZ_USER_AGENT: &str =
|
||||||
|
"aws-sdk-js/3.738.0 ua/2.1 os/other lang/js md/browser#unknown_unknown api/sso-oidc#3.738.0 m/E KiroIDE";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct KiroAuthConfig {
|
||||||
|
pub auth_method: Option<String>,
|
||||||
|
pub refresh_token: Option<String>,
|
||||||
|
pub expires_at: Option<u64>,
|
||||||
|
pub profile_arn: Option<String>,
|
||||||
|
pub region: Option<String>,
|
||||||
|
pub auth_region: Option<String>,
|
||||||
|
pub api_region: Option<String>,
|
||||||
|
pub client_id: Option<String>,
|
||||||
|
pub client_secret: Option<String>,
|
||||||
|
pub machine_id: Option<String>,
|
||||||
|
pub kiro_version: Option<String>,
|
||||||
|
pub system_version: Option<String>,
|
||||||
|
pub node_version: Option<String>,
|
||||||
|
pub access_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KiroAuthConfig {
|
||||||
|
pub fn from_json_value(value: &Value) -> Option<Self> {
|
||||||
|
let object = value.as_object()?;
|
||||||
|
Some(Self {
|
||||||
|
auth_method: string_field(
|
||||||
|
object,
|
||||||
|
&["auth_method", "authMethod", "auth_type", "authType"],
|
||||||
|
),
|
||||||
|
refresh_token: string_field(object, &["refresh_token", "refreshToken"]),
|
||||||
|
expires_at: u64_field(object.get("expires_at"))
|
||||||
|
.or_else(|| u64_field(object.get("expiresAt"))),
|
||||||
|
profile_arn: string_field(object, &["profile_arn", "profileArn"]),
|
||||||
|
region: string_field(object, &["region"]),
|
||||||
|
auth_region: string_field(object, &["auth_region", "authRegion"]),
|
||||||
|
api_region: string_field(object, &["api_region", "apiRegion"]),
|
||||||
|
client_id: string_field(object, &["client_id", "clientId"]),
|
||||||
|
client_secret: string_field(object, &["client_secret", "clientSecret"]),
|
||||||
|
machine_id: string_field(object, &["machine_id", "machineId"]),
|
||||||
|
kiro_version: string_field(object, &["kiro_version", "kiroVersion"]),
|
||||||
|
system_version: string_field(object, &["system_version", "systemVersion"]),
|
||||||
|
node_version: string_field(object, &["node_version", "nodeVersion"]),
|
||||||
|
access_token: string_field(object, &["access_token", "accessToken"]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_raw_json(raw: Option<&str>) -> Option<Self> {
|
||||||
|
let parsed: Value = serde_json::from_str(raw?.trim()).ok()?;
|
||||||
|
Self::from_json_value(&parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_json_value(&self) -> Value {
|
||||||
|
let mut object = serde_json::Map::new();
|
||||||
|
insert_string(&mut object, "auth_method", self.auth_method.as_deref());
|
||||||
|
insert_string(&mut object, "refresh_token", self.refresh_token.as_deref());
|
||||||
|
if let Some(expires_at) = self.expires_at {
|
||||||
|
object.insert("expires_at".to_string(), json!(expires_at));
|
||||||
|
}
|
||||||
|
insert_string(&mut object, "profile_arn", self.profile_arn.as_deref());
|
||||||
|
insert_string(&mut object, "region", self.region.as_deref());
|
||||||
|
insert_string(&mut object, "auth_region", self.auth_region.as_deref());
|
||||||
|
insert_string(&mut object, "api_region", self.api_region.as_deref());
|
||||||
|
insert_string(&mut object, "client_id", self.client_id.as_deref());
|
||||||
|
insert_string(&mut object, "client_secret", self.client_secret.as_deref());
|
||||||
|
insert_string(&mut object, "machine_id", self.machine_id.as_deref());
|
||||||
|
insert_string(&mut object, "kiro_version", self.kiro_version.as_deref());
|
||||||
|
insert_string(
|
||||||
|
&mut object,
|
||||||
|
"system_version",
|
||||||
|
self.system_version.as_deref(),
|
||||||
|
);
|
||||||
|
insert_string(&mut object, "node_version", self.node_version.as_deref());
|
||||||
|
insert_string(&mut object, "access_token", self.access_token.as_deref());
|
||||||
|
Value::Object(object)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn effective_auth_region(&self) -> &str {
|
||||||
|
self.auth_region
|
||||||
|
.as_deref()
|
||||||
|
.or(self.region.as_deref())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("us-east-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn effective_api_region(&self) -> &str {
|
||||||
|
self.api_region
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("us-east-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn effective_kiro_version(&self) -> &str {
|
||||||
|
self.kiro_version
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("0.3.210")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_idc_auth(&self) -> bool {
|
||||||
|
self.auth_method
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.map(str::to_ascii_lowercase)
|
||||||
|
.is_some_and(|value| matches!(value.as_str(), "idc" | "external_idp"))
|
||||||
|
|| (self
|
||||||
|
.client_id
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|value| !value.trim().is_empty())
|
||||||
|
&& self
|
||||||
|
.client_secret
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|value| !value.trim().is_empty()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_refresh_access_token(&self) -> bool {
|
||||||
|
self.refresh_token
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| value.len() >= 100 && !value.contains("..."))
|
||||||
|
.is_some()
|
||||||
|
&& (!self.is_idc_auth()
|
||||||
|
|| (self
|
||||||
|
.client_id
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|value| !value.trim().is_empty())
|
||||||
|
&& self
|
||||||
|
.client_secret
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|value| !value.trim().is_empty())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct KiroProviderOAuthAdapter {
|
||||||
|
social_refresh_base_url: Option<String>,
|
||||||
|
idc_refresh_base_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KiroProviderOAuthAdapter {
|
||||||
|
pub fn with_refresh_base_urls(
|
||||||
|
mut self,
|
||||||
|
social_refresh_base_url: Option<String>,
|
||||||
|
idc_refresh_base_url: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
self.social_refresh_base_url = social_refresh_base_url;
|
||||||
|
self.idc_refresh_base_url = idc_refresh_base_url;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh_auth_config(
|
||||||
|
&self,
|
||||||
|
executor: &dyn OAuthHttpExecutor,
|
||||||
|
ctx: &ProviderOAuthTransportContext,
|
||||||
|
auth_config: &KiroAuthConfig,
|
||||||
|
) -> Result<KiroAuthConfig, OAuthError> {
|
||||||
|
if auth_config.is_idc_auth() {
|
||||||
|
self.refresh_idc_token(executor, ctx, auth_config).await
|
||||||
|
} else {
|
||||||
|
self.refresh_social_token(executor, ctx, auth_config).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh_social_token(
|
||||||
|
&self,
|
||||||
|
executor: &dyn OAuthHttpExecutor,
|
||||||
|
ctx: &ProviderOAuthTransportContext,
|
||||||
|
auth_config: &KiroAuthConfig,
|
||||||
|
) -> Result<KiroAuthConfig, OAuthError> {
|
||||||
|
let url = self
|
||||||
|
.social_refresh_base_url
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(|base| format!("{}/refreshToken", base.trim_end_matches('/')))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"https://prod.{}.auth.desktop.kiro.dev/refreshToken",
|
||||||
|
auth_config.effective_auth_region()
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let machine_id = generate_kiro_machine_id(auth_config, None)
|
||||||
|
.ok_or_else(|| OAuthError::invalid_request("missing machine_id seed"))?;
|
||||||
|
let response = executor
|
||||||
|
.execute(OAuthHttpRequest {
|
||||||
|
request_id: "provider-oauth:kiro-social-refresh".to_string(),
|
||||||
|
method: reqwest::Method::POST,
|
||||||
|
url,
|
||||||
|
headers: BTreeMap::from([
|
||||||
|
(
|
||||||
|
"user-agent".to_string(),
|
||||||
|
format!(
|
||||||
|
"KiroIDE-{}-{machine_id}",
|
||||||
|
auth_config.effective_kiro_version()
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"accept".to_string(),
|
||||||
|
"application/json, text/plain, */*".to_string(),
|
||||||
|
),
|
||||||
|
("content-type".to_string(), "application/json".to_string()),
|
||||||
|
("connection".to_string(), "close".to_string()),
|
||||||
|
]),
|
||||||
|
content_type: Some("application/json".to_string()),
|
||||||
|
json_body: Some(json!({
|
||||||
|
"refreshToken": auth_config.refresh_token.as_deref().unwrap_or_default()
|
||||||
|
})),
|
||||||
|
body_bytes: None,
|
||||||
|
network: ctx.network.clone(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
if !(200..300).contains(&response.status_code) {
|
||||||
|
return Err(OAuthError::HttpStatus {
|
||||||
|
status_code: response.status_code,
|
||||||
|
body_excerpt: response.body_text.chars().take(500).collect(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let payload = response
|
||||||
|
.json_body
|
||||||
|
.or_else(|| serde_json::from_str::<Value>(&response.body_text).ok())
|
||||||
|
.ok_or_else(|| OAuthError::invalid_response("kiro refresh response is not json"))?;
|
||||||
|
let access_token = payload
|
||||||
|
.get("accessToken")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| OAuthError::invalid_response("kiro refresh missing accessToken"))?;
|
||||||
|
let mut refreshed = auth_config.clone();
|
||||||
|
refreshed.access_token = Some(access_token.to_string());
|
||||||
|
refreshed.expires_at = Some(resolve_expires_at(&payload));
|
||||||
|
if refreshed
|
||||||
|
.machine_id
|
||||||
|
.as_deref()
|
||||||
|
.is_none_or(|value| value.trim().is_empty())
|
||||||
|
{
|
||||||
|
refreshed.machine_id = Some(machine_id);
|
||||||
|
}
|
||||||
|
if let Some(refresh_token) = payload.get("refreshToken").and_then(Value::as_str) {
|
||||||
|
refreshed.refresh_token = Some(refresh_token.trim().to_string());
|
||||||
|
}
|
||||||
|
if let Some(profile_arn) = payload.get("profileArn").and_then(Value::as_str) {
|
||||||
|
refreshed.profile_arn = Some(profile_arn.trim().to_string());
|
||||||
|
}
|
||||||
|
Ok(refreshed)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh_idc_token(
|
||||||
|
&self,
|
||||||
|
executor: &dyn OAuthHttpExecutor,
|
||||||
|
ctx: &ProviderOAuthTransportContext,
|
||||||
|
auth_config: &KiroAuthConfig,
|
||||||
|
) -> Result<KiroAuthConfig, OAuthError> {
|
||||||
|
let url = self
|
||||||
|
.idc_refresh_base_url
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(|base| format!("{}/token", base.trim_end_matches('/')))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"https://oidc.{}.amazonaws.com/token",
|
||||||
|
auth_config.effective_auth_region()
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let response = executor
|
||||||
|
.execute(OAuthHttpRequest {
|
||||||
|
request_id: "provider-oauth:kiro-idc-refresh".to_string(),
|
||||||
|
method: reqwest::Method::POST,
|
||||||
|
url,
|
||||||
|
headers: BTreeMap::from([
|
||||||
|
("content-type".to_string(), "application/json".to_string()),
|
||||||
|
(
|
||||||
|
"x-amz-user-agent".to_string(),
|
||||||
|
IDC_AMZ_USER_AGENT.to_string(),
|
||||||
|
),
|
||||||
|
("user-agent".to_string(), "node".to_string()),
|
||||||
|
("accept".to_string(), "*/*".to_string()),
|
||||||
|
]),
|
||||||
|
content_type: Some("application/json".to_string()),
|
||||||
|
json_body: Some(json!({
|
||||||
|
"clientId": auth_config.client_id.as_deref().unwrap_or_default(),
|
||||||
|
"clientSecret": auth_config.client_secret.as_deref().unwrap_or_default(),
|
||||||
|
"refreshToken": auth_config.refresh_token.as_deref().unwrap_or_default(),
|
||||||
|
"grantType": "refresh_token",
|
||||||
|
})),
|
||||||
|
body_bytes: None,
|
||||||
|
network: ctx.network.clone(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
if !(200..300).contains(&response.status_code) {
|
||||||
|
return Err(OAuthError::HttpStatus {
|
||||||
|
status_code: response.status_code,
|
||||||
|
body_excerpt: response.body_text.chars().take(500).collect(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let payload = response
|
||||||
|
.json_body
|
||||||
|
.or_else(|| serde_json::from_str::<Value>(&response.body_text).ok())
|
||||||
|
.ok_or_else(|| OAuthError::invalid_response("kiro idc response is not json"))?;
|
||||||
|
let access_token = payload
|
||||||
|
.get("accessToken")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| OAuthError::invalid_response("kiro idc missing accessToken"))?;
|
||||||
|
let mut refreshed = auth_config.clone();
|
||||||
|
refreshed.access_token = Some(access_token.to_string());
|
||||||
|
refreshed.expires_at = Some(resolve_expires_at(&payload));
|
||||||
|
if refreshed
|
||||||
|
.machine_id
|
||||||
|
.as_deref()
|
||||||
|
.is_none_or(|value| value.trim().is_empty())
|
||||||
|
{
|
||||||
|
refreshed.machine_id = generate_kiro_machine_id(auth_config, None);
|
||||||
|
}
|
||||||
|
if let Some(refresh_token) = payload.get("refreshToken").and_then(Value::as_str) {
|
||||||
|
refreshed.refresh_token = Some(refresh_token.trim().to_string());
|
||||||
|
}
|
||||||
|
Ok(refreshed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ProviderOAuthAdapter for KiroProviderOAuthAdapter {
|
||||||
|
fn provider_type(&self) -> &'static str {
|
||||||
|
KIRO_PROVIDER_TYPE
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capabilities(&self) -> ProviderOAuthCapabilities {
|
||||||
|
ProviderOAuthCapabilities {
|
||||||
|
supports_authorization_code: false,
|
||||||
|
supports_refresh_token_import: true,
|
||||||
|
supports_batch_import: true,
|
||||||
|
supports_device_flow: true,
|
||||||
|
supports_account_probe: true,
|
||||||
|
rotates_refresh_token: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn import_credentials(
|
||||||
|
&self,
|
||||||
|
executor: &dyn OAuthHttpExecutor,
|
||||||
|
ctx: &ProviderOAuthTransportContext,
|
||||||
|
input: ProviderOAuthImportInput,
|
||||||
|
) -> Result<ProviderOAuthTokenSet, OAuthError> {
|
||||||
|
let auth_config = input
|
||||||
|
.raw_credentials
|
||||||
|
.as_ref()
|
||||||
|
.and_then(KiroAuthConfig::from_json_value)
|
||||||
|
.or_else(|| {
|
||||||
|
input
|
||||||
|
.refresh_token
|
||||||
|
.as_ref()
|
||||||
|
.map(|refresh_token| KiroAuthConfig {
|
||||||
|
auth_method: None,
|
||||||
|
refresh_token: Some(refresh_token.clone()),
|
||||||
|
expires_at: None,
|
||||||
|
profile_arn: None,
|
||||||
|
region: None,
|
||||||
|
auth_region: None,
|
||||||
|
api_region: None,
|
||||||
|
client_id: None,
|
||||||
|
client_secret: None,
|
||||||
|
machine_id: None,
|
||||||
|
kiro_version: None,
|
||||||
|
system_version: None,
|
||||||
|
node_version: None,
|
||||||
|
access_token: None,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.ok_or_else(|| OAuthError::invalid_request("kiro credentials are required"))?;
|
||||||
|
let refreshed = self
|
||||||
|
.refresh_auth_config(executor, ctx, &auth_config)
|
||||||
|
.await?;
|
||||||
|
token_set_from_kiro_auth_config(refreshed)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh(
|
||||||
|
&self,
|
||||||
|
executor: &dyn OAuthHttpExecutor,
|
||||||
|
ctx: &ProviderOAuthTransportContext,
|
||||||
|
account: &ProviderOAuthAccount,
|
||||||
|
) -> Result<ProviderOAuthTokenSet, OAuthError> {
|
||||||
|
let auth_config = KiroAuthConfig::from_json_value(&account.auth_config)
|
||||||
|
.ok_or_else(|| OAuthError::invalid_request("invalid kiro auth_config"))?;
|
||||||
|
let refreshed = self
|
||||||
|
.refresh_auth_config(executor, ctx, &auth_config)
|
||||||
|
.await?;
|
||||||
|
token_set_from_kiro_auth_config(refreshed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_request_auth(
|
||||||
|
&self,
|
||||||
|
account: &ProviderOAuthAccount,
|
||||||
|
) -> Result<ProviderOAuthRequestAuth, OAuthError> {
|
||||||
|
let auth_config = KiroAuthConfig::from_json_value(&account.auth_config)
|
||||||
|
.ok_or_else(|| OAuthError::invalid_request("invalid kiro auth_config"))?;
|
||||||
|
let machine_id = generate_kiro_machine_id(&auth_config, Some(&account.access_token))
|
||||||
|
.ok_or_else(|| OAuthError::invalid_request("missing kiro machine_id"))?;
|
||||||
|
Ok(ProviderOAuthRequestAuth::Kiro {
|
||||||
|
name: "authorization".to_string(),
|
||||||
|
value: format!("Bearer {}", account.access_token.trim()),
|
||||||
|
auth_config: account.auth_config.clone(),
|
||||||
|
machine_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn account_fingerprint(&self, account: &ProviderOAuthAccount) -> Option<String> {
|
||||||
|
account
|
||||||
|
.auth_config
|
||||||
|
.get("refresh_token")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(secret_fingerprint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_kiro_machine_id(
|
||||||
|
auth_config: &KiroAuthConfig,
|
||||||
|
fallback_secret: Option<&str>,
|
||||||
|
) -> Option<String> {
|
||||||
|
if let Some(machine_id) = auth_config
|
||||||
|
.machine_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(normalize_machine_id)
|
||||||
|
{
|
||||||
|
return Some(machine_id);
|
||||||
|
}
|
||||||
|
let seed = auth_config
|
||||||
|
.refresh_token
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.or_else(|| {
|
||||||
|
fallback_secret
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
})?;
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(b"KotlinNativeAPI/");
|
||||||
|
hasher.update(seed.as_bytes());
|
||||||
|
Some(format!("{:x}", hasher.finalize()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_set_from_kiro_auth_config(
|
||||||
|
auth_config: KiroAuthConfig,
|
||||||
|
) -> Result<ProviderOAuthTokenSet, OAuthError> {
|
||||||
|
let access_token = auth_config
|
||||||
|
.access_token
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| OAuthError::invalid_response("kiro auth_config missing access_token"))?
|
||||||
|
.to_string();
|
||||||
|
let token_set = crate::core::OAuthTokenSet {
|
||||||
|
access_token,
|
||||||
|
refresh_token: auth_config.refresh_token.clone(),
|
||||||
|
token_type: Some("Bearer".to_string()),
|
||||||
|
scope: None,
|
||||||
|
expires_at_unix_secs: auth_config.expires_at,
|
||||||
|
raw_payload: Some(auth_config.to_json_value()),
|
||||||
|
};
|
||||||
|
let mut value = auth_config.to_json_value();
|
||||||
|
if let Some(object) = value.as_object_mut() {
|
||||||
|
object.insert("provider_type".to_string(), json!(KIRO_PROVIDER_TYPE));
|
||||||
|
}
|
||||||
|
Ok(ProviderOAuthTokenSet {
|
||||||
|
token_set,
|
||||||
|
auth_config: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_expires_at(payload: &Value) -> u64 {
|
||||||
|
let expires_in = payload
|
||||||
|
.get("expiresIn")
|
||||||
|
.or_else(|| payload.get("expires_in"))
|
||||||
|
.and_then(|value| {
|
||||||
|
value
|
||||||
|
.as_u64()
|
||||||
|
.or_else(|| value.as_str()?.parse::<u64>().ok())
|
||||||
|
})
|
||||||
|
.unwrap_or(3600);
|
||||||
|
current_unix_secs().saturating_add(expires_in)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_machine_id(raw: &str) -> Option<String> {
|
||||||
|
let raw = raw.trim();
|
||||||
|
if raw.len() == 64 && raw.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||||
|
return Some(raw.to_ascii_lowercase());
|
||||||
|
}
|
||||||
|
if raw.len() == 36
|
||||||
|
&& raw.chars().enumerate().all(|(idx, ch)| match idx {
|
||||||
|
8 | 13 | 18 | 23 => ch == '-',
|
||||||
|
_ => ch.is_ascii_hexdigit(),
|
||||||
|
})
|
||||||
|
{
|
||||||
|
let normalized = raw.replace('-', "").to_ascii_lowercase();
|
||||||
|
return Some(format!("{normalized}{normalized}"));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_field(object: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<String> {
|
||||||
|
keys.iter()
|
||||||
|
.find_map(|key| object.get(*key))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn u64_field(value: Option<&Value>) -> Option<u64> {
|
||||||
|
match value? {
|
||||||
|
Value::Number(number) => number.as_u64(),
|
||||||
|
Value::String(value) => value.trim().parse::<u64>().ok(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_string(object: &mut serde_json::Map<String, Value>, key: &str, value: Option<&str>) {
|
||||||
|
if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
|
||||||
|
object.insert(key.to_string(), Value::String(value.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn secret_fingerprint(value: &str) -> String {
|
||||||
|
let digest = Sha256::digest(value.as_bytes());
|
||||||
|
digest
|
||||||
|
.iter()
|
||||||
|
.take(8)
|
||||||
|
.map(|byte| format!("{byte:02x}"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{generate_kiro_machine_id, KiroAuthConfig};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_kiro_uuid_machine_id() {
|
||||||
|
let auth_config = KiroAuthConfig {
|
||||||
|
auth_method: None,
|
||||||
|
refresh_token: Some("r".repeat(128)),
|
||||||
|
expires_at: None,
|
||||||
|
profile_arn: None,
|
||||||
|
region: None,
|
||||||
|
auth_region: None,
|
||||||
|
api_region: None,
|
||||||
|
client_id: None,
|
||||||
|
client_secret: None,
|
||||||
|
machine_id: Some("123e4567-e89b-12d3-a456-426614174000".to_string()),
|
||||||
|
kiro_version: None,
|
||||||
|
system_version: None,
|
||||||
|
node_version: None,
|
||||||
|
access_token: None,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
generate_kiro_machine_id(&auth_config, None).as_deref(),
|
||||||
|
Some("123e4567e89b12d3a456426614174000123e4567e89b12d3a456426614174000")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
crates/aether-oauth/src/provider/providers/mod.rs
Normal file
13
crates/aether-oauth/src/provider/providers/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
mod antigravity;
|
||||||
|
mod codex;
|
||||||
|
mod generic;
|
||||||
|
mod kiro;
|
||||||
|
|
||||||
|
pub use antigravity::AntigravityProviderOAuthAdapter;
|
||||||
|
pub use codex::CodexProviderOAuthAdapter;
|
||||||
|
pub use generic::{
|
||||||
|
GenericProviderOAuthAdapter, GenericProviderOAuthTemplate, GENERIC_PROVIDER_OAUTH_TEMPLATES,
|
||||||
|
};
|
||||||
|
pub use kiro::{
|
||||||
|
generate_kiro_machine_id, KiroAuthConfig, KiroProviderOAuthAdapter, KIRO_PROVIDER_TYPE,
|
||||||
|
};
|
||||||
132
crates/aether-oauth/src/provider/service.rs
Normal file
132
crates/aether-oauth/src/provider/service.rs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
use super::{
|
||||||
|
ProviderOAuthAdapter, ProviderOAuthImportInput, ProviderOAuthProbeResult,
|
||||||
|
ProviderOAuthRequestAuth, ProviderOAuthTokenSet, ProviderOAuthTransportContext,
|
||||||
|
};
|
||||||
|
use crate::core::{OAuthAdapterRegistry, OAuthAuthorizeResponse, OAuthError};
|
||||||
|
use crate::network::OAuthHttpExecutor;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ProviderOAuthService {
|
||||||
|
registry: OAuthAdapterRegistry<dyn ProviderOAuthAdapter>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderOAuthService {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_builtin_adapters() -> Self {
|
||||||
|
use super::providers::{
|
||||||
|
AntigravityProviderOAuthAdapter, CodexProviderOAuthAdapter,
|
||||||
|
GenericProviderOAuthAdapter, KiroProviderOAuthAdapter,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut service = Self::new()
|
||||||
|
.with_adapter(Arc::new(KiroProviderOAuthAdapter::default()))
|
||||||
|
.with_adapter(Arc::new(CodexProviderOAuthAdapter::default()))
|
||||||
|
.with_adapter(Arc::new(AntigravityProviderOAuthAdapter::default()));
|
||||||
|
for provider_type in ["claude_code", "gemini_cli"] {
|
||||||
|
if let Some(adapter) = GenericProviderOAuthAdapter::for_provider_type(provider_type) {
|
||||||
|
service = service.with_adapter(Arc::new(adapter));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
service
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_adapter(mut self, adapter: Arc<dyn ProviderOAuthAdapter>) -> Self {
|
||||||
|
self.registry.insert(adapter.provider_type(), adapter);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn adapter(
|
||||||
|
&self,
|
||||||
|
provider_type: &str,
|
||||||
|
) -> Result<Arc<dyn ProviderOAuthAdapter>, OAuthError> {
|
||||||
|
self.registry
|
||||||
|
.get(provider_type)
|
||||||
|
.ok_or_else(|| OAuthError::UnsupportedProvider(provider_type.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_authorize_url(
|
||||||
|
&self,
|
||||||
|
ctx: &ProviderOAuthTransportContext,
|
||||||
|
state: &str,
|
||||||
|
code_challenge: Option<&str>,
|
||||||
|
) -> Result<OAuthAuthorizeResponse, OAuthError> {
|
||||||
|
self.adapter(&ctx.provider_type)?
|
||||||
|
.build_authorize_url(ctx, state, code_challenge)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn exchange_code(
|
||||||
|
&self,
|
||||||
|
executor: &dyn OAuthHttpExecutor,
|
||||||
|
ctx: &ProviderOAuthTransportContext,
|
||||||
|
code: &str,
|
||||||
|
state: &str,
|
||||||
|
pkce_verifier: Option<&str>,
|
||||||
|
) -> Result<ProviderOAuthTokenSet, OAuthError> {
|
||||||
|
self.adapter(&ctx.provider_type)?
|
||||||
|
.exchange_code(executor, ctx, code, state, pkce_verifier)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn import_credentials(
|
||||||
|
&self,
|
||||||
|
executor: &dyn OAuthHttpExecutor,
|
||||||
|
ctx: &ProviderOAuthTransportContext,
|
||||||
|
input: ProviderOAuthImportInput,
|
||||||
|
) -> Result<ProviderOAuthTokenSet, OAuthError> {
|
||||||
|
self.adapter(&ctx.provider_type)?
|
||||||
|
.import_credentials(executor, ctx, input)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn refresh(
|
||||||
|
&self,
|
||||||
|
executor: &dyn OAuthHttpExecutor,
|
||||||
|
ctx: &ProviderOAuthTransportContext,
|
||||||
|
account: &super::ProviderOAuthAccount,
|
||||||
|
) -> Result<ProviderOAuthTokenSet, OAuthError> {
|
||||||
|
self.adapter(&ctx.provider_type)?
|
||||||
|
.refresh(executor, ctx, account)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_request_auth(
|
||||||
|
&self,
|
||||||
|
account: &super::ProviderOAuthAccount,
|
||||||
|
) -> Result<ProviderOAuthRequestAuth, OAuthError> {
|
||||||
|
self.adapter(&account.provider_type)?
|
||||||
|
.resolve_request_auth(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn probe_account_state(
|
||||||
|
&self,
|
||||||
|
executor: &dyn OAuthHttpExecutor,
|
||||||
|
ctx: &ProviderOAuthTransportContext,
|
||||||
|
account: &super::ProviderOAuthAccount,
|
||||||
|
) -> Result<Option<ProviderOAuthProbeResult>, OAuthError> {
|
||||||
|
self.adapter(&account.provider_type)?
|
||||||
|
.probe_account_state(executor, ctx, account)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::ProviderOAuthService;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builtin_provider_service_registers_supported_provider_types() {
|
||||||
|
let service = ProviderOAuthService::with_builtin_adapters();
|
||||||
|
|
||||||
|
for provider_type in ["claude_code", "codex", "gemini_cli", "antigravity", "kiro"] {
|
||||||
|
assert!(
|
||||||
|
service.adapter(provider_type).is_ok(),
|
||||||
|
"{provider_type} adapter should be registered"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(service.adapter("unknown").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ aether-contracts.workspace = true
|
|||||||
aether-crypto.workspace = true
|
aether-crypto.workspace = true
|
||||||
aether-data.workspace = true
|
aether-data.workspace = true
|
||||||
aether-data-contracts.workspace = true
|
aether-data-contracts.workspace = true
|
||||||
|
aether-oauth.workspace = true
|
||||||
aether-video-tasks-core.workspace = true
|
aether-video-tasks-core.workspace = true
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
http.workspace = true
|
http.workspace = true
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
|
use aether_oauth::provider::providers::{
|
||||||
|
GenericProviderOAuthAdapter, GENERIC_PROVIDER_OAUTH_TEMPLATES,
|
||||||
|
};
|
||||||
|
use aether_oauth::provider::{ProviderOAuthAccount, ProviderOAuthAdapter, ProviderOAuthTokenSet};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::{json, Value};
|
use serde_json::Value;
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use url::form_urlencoded;
|
|
||||||
|
|
||||||
use super::oauth_refresh::{
|
use super::oauth_refresh::{
|
||||||
CachedOAuthEntry, LocalOAuthHttpExecutor, LocalOAuthHttpRequest, LocalOAuthRefreshAdapter,
|
oauth_error_to_local_refresh_error, provider_oauth_transport_context_from_snapshot,
|
||||||
LocalOAuthRefreshError, LocalResolvedOAuthRequestAuth,
|
CachedOAuthEntry, LocalOAuthHttpExecutor, LocalOAuthRefreshAdapter, LocalOAuthRefreshError,
|
||||||
|
LocalResolvedOAuthRequestAuth, ProviderOAuthLocalHttpExecutor,
|
||||||
};
|
};
|
||||||
use super::snapshot::GatewayProviderTransportSnapshot;
|
use super::snapshot::GatewayProviderTransportSnapshot;
|
||||||
|
|
||||||
@@ -16,66 +18,11 @@ const AUTH_HEADER_NAME: &str = "authorization";
|
|||||||
const OAUTH_REFRESH_SKEW_SECS: u64 = 120;
|
const OAUTH_REFRESH_SKEW_SECS: u64 = 120;
|
||||||
const PLACEHOLDER_API_KEY: &str = "__placeholder__";
|
const PLACEHOLDER_API_KEY: &str = "__placeholder__";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
struct GenericOAuthTemplate {
|
|
||||||
provider_type: &'static str,
|
|
||||||
token_url: &'static str,
|
|
||||||
client_id: &'static str,
|
|
||||||
client_secret: &'static str,
|
|
||||||
scopes: &'static [&'static str],
|
|
||||||
uses_json_payload: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
const GENERIC_OAUTH_TEMPLATES: &[GenericOAuthTemplate] = &[
|
|
||||||
GenericOAuthTemplate {
|
|
||||||
provider_type: "claude_code",
|
|
||||||
token_url: "https://console.anthropic.com/v1/oauth/token",
|
|
||||||
client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
|
||||||
client_secret: "",
|
|
||||||
scopes: &["org:create_api_key", "user:profile", "user:inference"],
|
|
||||||
uses_json_payload: true,
|
|
||||||
},
|
|
||||||
GenericOAuthTemplate {
|
|
||||||
provider_type: "codex",
|
|
||||||
token_url: "https://auth.openai.com/oauth/token",
|
|
||||||
client_id: "app_EMoamEEZ73f0CkXaXp7hrann",
|
|
||||||
client_secret: "",
|
|
||||||
scopes: &["openid", "email", "profile", "offline_access"],
|
|
||||||
uses_json_payload: false,
|
|
||||||
},
|
|
||||||
GenericOAuthTemplate {
|
|
||||||
provider_type: "gemini_cli",
|
|
||||||
token_url: "https://oauth2.googleapis.com/token",
|
|
||||||
client_id: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
|
|
||||||
client_secret: "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",
|
|
||||||
scopes: &[
|
|
||||||
"https://www.googleapis.com/auth/cloud-platform",
|
|
||||||
"https://www.googleapis.com/auth/userinfo.email",
|
|
||||||
"https://www.googleapis.com/auth/userinfo.profile",
|
|
||||||
],
|
|
||||||
uses_json_payload: false,
|
|
||||||
},
|
|
||||||
GenericOAuthTemplate {
|
|
||||||
provider_type: "antigravity",
|
|
||||||
token_url: "https://oauth2.googleapis.com/token",
|
|
||||||
client_id: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
|
|
||||||
client_secret: "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",
|
|
||||||
scopes: &[
|
|
||||||
"https://www.googleapis.com/auth/cloud-platform",
|
|
||||||
"https://www.googleapis.com/auth/userinfo.email",
|
|
||||||
"https://www.googleapis.com/auth/userinfo.profile",
|
|
||||||
"https://www.googleapis.com/auth/cclog",
|
|
||||||
"https://www.googleapis.com/auth/experimentsandconfigs",
|
|
||||||
],
|
|
||||||
uses_json_payload: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
pub fn supports_local_generic_oauth_request_auth_resolution(
|
pub fn supports_local_generic_oauth_request_auth_resolution(
|
||||||
transport: &GatewayProviderTransportSnapshot,
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
transport.key.auth_type.trim().eq_ignore_ascii_case("oauth")
|
transport.key.auth_type.trim().eq_ignore_ascii_case("oauth")
|
||||||
&& template_for_provider_type(transport.provider.provider_type.as_str()).is_some()
|
&& generic_provider_type(transport.provider.provider_type.as_str()).is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
@@ -94,11 +41,15 @@ impl GenericOAuthRefreshAdapter {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn token_url_for_template(&self, template: GenericOAuthTemplate) -> String {
|
fn adapter_for_provider_type(
|
||||||
self.token_url_overrides
|
&self,
|
||||||
.get(template.provider_type)
|
provider_type: &'static str,
|
||||||
.cloned()
|
) -> Option<GenericProviderOAuthAdapter> {
|
||||||
.unwrap_or_else(|| template.token_url.to_string())
|
let adapter = GenericProviderOAuthAdapter::for_provider_type(provider_type)?;
|
||||||
|
if let Some(token_url) = self.token_url_overrides.get(provider_type) {
|
||||||
|
return Some(adapter.with_token_url_override(token_url.clone()));
|
||||||
|
}
|
||||||
|
Some(adapter)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn auth_config_from_transport(transport: &GatewayProviderTransportSnapshot) -> Option<Value> {
|
fn auth_config_from_transport(transport: &GatewayProviderTransportSnapshot) -> Option<Value> {
|
||||||
@@ -186,18 +137,15 @@ impl GenericOAuthRefreshAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_cached_entry(
|
fn build_cached_entry(
|
||||||
&self,
|
provider_type: &'static str,
|
||||||
template: GenericOAuthTemplate,
|
refreshed: ProviderOAuthTokenSet,
|
||||||
access_token: &str,
|
|
||||||
metadata: Value,
|
|
||||||
expires_at_unix_secs: Option<u64>,
|
|
||||||
) -> CachedOAuthEntry {
|
) -> CachedOAuthEntry {
|
||||||
CachedOAuthEntry {
|
CachedOAuthEntry {
|
||||||
provider_type: template.provider_type.to_string(),
|
provider_type: provider_type.to_string(),
|
||||||
auth_header_name: AUTH_HEADER_NAME.to_string(),
|
auth_header_name: AUTH_HEADER_NAME.to_string(),
|
||||||
auth_header_value: format!("Bearer {access_token}"),
|
auth_header_value: refreshed.token_set.bearer_header_value(),
|
||||||
expires_at_unix_secs,
|
expires_at_unix_secs: refreshed.token_set.expires_at_unix_secs,
|
||||||
metadata: Some(metadata),
|
metadata: Some(refreshed.auth_config),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,252 +222,70 @@ impl LocalOAuthRefreshAdapter for GenericOAuthRefreshAdapter {
|
|||||||
transport: &GatewayProviderTransportSnapshot,
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
entry: Option<&CachedOAuthEntry>,
|
entry: Option<&CachedOAuthEntry>,
|
||||||
) -> Result<Option<CachedOAuthEntry>, LocalOAuthRefreshError> {
|
) -> Result<Option<CachedOAuthEntry>, LocalOAuthRefreshError> {
|
||||||
let Some(template) = template_for_provider_type(transport.provider.provider_type.as_str())
|
let Some(provider_type) = generic_provider_type(transport.provider.provider_type.as_str())
|
||||||
else {
|
else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let cached_auth_config =
|
let Some(auth_config) = self.base_auth_config(transport, entry) else {
|
||||||
entry.and_then(|cached| Self::auth_config_from_entry(transport, cached));
|
return Ok(None);
|
||||||
let transport_auth_config = Self::auth_config_from_transport(transport);
|
|
||||||
let base_auth_config = self.base_auth_config(transport, entry);
|
|
||||||
let base_auth_config_source = match (
|
|
||||||
base_auth_config.as_ref(),
|
|
||||||
cached_auth_config.as_ref(),
|
|
||||||
transport_auth_config.as_ref(),
|
|
||||||
) {
|
|
||||||
(Some(selected), Some(cached), Some(transport_auth))
|
|
||||||
if selected == transport_auth && selected != cached =>
|
|
||||||
{
|
|
||||||
"transport_auth_config"
|
|
||||||
}
|
|
||||||
(Some(selected), Some(cached), Some(transport_auth))
|
|
||||||
if selected == cached && selected != transport_auth =>
|
|
||||||
{
|
|
||||||
"cached_entry"
|
|
||||||
}
|
|
||||||
(Some(_), Some(_), Some(_)) => "cached_entry",
|
|
||||||
(Some(_), Some(_), None) => "cached_entry",
|
|
||||||
(Some(_), None, Some(_)) => "transport_auth_config",
|
|
||||||
_ => "none",
|
|
||||||
};
|
};
|
||||||
let mut metadata = base_auth_config
|
let Some(refresh_token) = refresh_token_from_auth_config(&auth_config) else {
|
||||||
.and_then(|value| value.as_object().cloned())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let Some(refresh_token) = metadata.get("refresh_token").and_then(non_empty_string) else {
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
key_id = %transport.key.id,
|
key_id = %transport.key.id,
|
||||||
provider_id = %transport.provider.id,
|
provider_id = %transport.provider.id,
|
||||||
provider_type = template.provider_type,
|
provider_type,
|
||||||
auth_config_source = base_auth_config_source,
|
|
||||||
"gateway generic oauth refresh skipped because auth_config has no refresh_token"
|
"gateway generic oauth refresh skipped because auth_config has no refresh_token"
|
||||||
);
|
);
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
let Some(adapter) = self.adapter_for_provider_type(provider_type) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
let token_url = self.token_url_for_template(template);
|
|
||||||
let request_refresh_token_fingerprint = secret_fingerprint(refresh_token.as_str());
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
key_id = %transport.key.id,
|
key_id = %transport.key.id,
|
||||||
provider_id = %transport.provider.id,
|
provider_id = %transport.provider.id,
|
||||||
endpoint_id = %transport.endpoint.id,
|
endpoint_id = %transport.endpoint.id,
|
||||||
provider_type = template.provider_type,
|
provider_type,
|
||||||
auth_config_source = base_auth_config_source,
|
|
||||||
request_refresh_token_fingerprint = %request_refresh_token_fingerprint,
|
|
||||||
request_refresh_token_len = refresh_token.len(),
|
request_refresh_token_len = refresh_token.len(),
|
||||||
token_url = %token_url,
|
"gateway generic oauth refresh delegated to provider oauth adapter"
|
||||||
uses_json_payload = template.uses_json_payload,
|
|
||||||
"gateway generic oauth refresh request prepared"
|
|
||||||
);
|
);
|
||||||
let scope = (!template.scopes.is_empty()).then(|| template.scopes.join(" "));
|
|
||||||
let response = if template.uses_json_payload {
|
|
||||||
let mut body = serde_json::Map::from_iter([
|
|
||||||
(
|
|
||||||
"grant_type".to_string(),
|
|
||||||
Value::String("refresh_token".to_string()),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"client_id".to_string(),
|
|
||||||
Value::String(template.client_id.to_string()),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"refresh_token".to_string(),
|
|
||||||
Value::String(refresh_token.clone()),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
if let Some(scope) = scope.as_ref() {
|
|
||||||
body.insert("scope".to_string(), Value::String(scope.clone()));
|
|
||||||
}
|
|
||||||
executor
|
|
||||||
.execute(
|
|
||||||
template.provider_type,
|
|
||||||
transport,
|
|
||||||
&LocalOAuthHttpRequest {
|
|
||||||
request_id: "provider-oauth:local-refresh-token",
|
|
||||||
method: reqwest::Method::POST,
|
|
||||||
url: token_url,
|
|
||||||
headers: BTreeMap::from([
|
|
||||||
("content-type".to_string(), "application/json".to_string()),
|
|
||||||
("accept".to_string(), "application/json".to_string()),
|
|
||||||
]),
|
|
||||||
json_body: Some(Value::Object(body)),
|
|
||||||
body_bytes: None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.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.as_str());
|
|
||||||
if let Some(scope) = scope.as_ref() {
|
|
||||||
form.append_pair("scope", scope);
|
|
||||||
}
|
|
||||||
if !template.client_secret.trim().is_empty() {
|
|
||||||
form.append_pair("client_secret", template.client_secret);
|
|
||||||
}
|
|
||||||
form.finish().into_bytes()
|
|
||||||
};
|
|
||||||
executor
|
|
||||||
.execute(
|
|
||||||
template.provider_type,
|
|
||||||
transport,
|
|
||||||
&LocalOAuthHttpRequest {
|
|
||||||
request_id: "provider-oauth:local-refresh-token",
|
|
||||||
method: reqwest::Method::POST,
|
|
||||||
url: token_url,
|
|
||||||
headers: BTreeMap::from([
|
|
||||||
(
|
|
||||||
"content-type".to_string(),
|
|
||||||
"application/x-www-form-urlencoded".to_string(),
|
|
||||||
),
|
|
||||||
("accept".to_string(), "application/json".to_string()),
|
|
||||||
]),
|
|
||||||
json_body: None,
|
|
||||||
body_bytes: Some(form_body),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
let status = reqwest::StatusCode::from_u16(response.status_code).unwrap_or_default();
|
|
||||||
let body = response.body_text;
|
|
||||||
if !status.is_success() {
|
|
||||||
let body_excerpt = truncate_body(&body);
|
|
||||||
tracing::warn!(
|
|
||||||
key_id = %transport.key.id,
|
|
||||||
provider_id = %transport.provider.id,
|
|
||||||
endpoint_id = %transport.endpoint.id,
|
|
||||||
provider_type = template.provider_type,
|
|
||||||
status_code = status.as_u16(),
|
|
||||||
request_refresh_token_fingerprint = %request_refresh_token_fingerprint,
|
|
||||||
body_excerpt = %body_excerpt,
|
|
||||||
"gateway generic oauth refresh returned error status"
|
|
||||||
);
|
|
||||||
return Err(LocalOAuthRefreshError::HttpStatus {
|
|
||||||
provider_type: template.provider_type,
|
|
||||||
status_code: status.as_u16(),
|
|
||||||
body_excerpt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload: Value =
|
let oauth_executor =
|
||||||
serde_json::from_str(&body).map_err(|_| LocalOAuthRefreshError::InvalidResponse {
|
ProviderOAuthLocalHttpExecutor::new(provider_type, transport, executor);
|
||||||
provider_type: template.provider_type,
|
let ctx = provider_oauth_transport_context_from_snapshot(transport);
|
||||||
message: "generic oauth refresh returned non-json body".to_string(),
|
let account = ProviderOAuthAccount {
|
||||||
})?;
|
provider_type: provider_type.to_string(),
|
||||||
let Some(access_token) = payload.get("access_token").and_then(non_empty_string) else {
|
access_token: current_access_token(transport, entry).unwrap_or_default(),
|
||||||
return Err(LocalOAuthRefreshError::InvalidResponse {
|
expires_at_unix_secs: auth_config_expires_at(&auth_config),
|
||||||
provider_type: template.provider_type,
|
auth_config,
|
||||||
message: "generic oauth refresh returned empty access_token".to_string(),
|
identity: BTreeMap::new(),
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
let refreshed = adapter
|
||||||
|
.refresh(&oauth_executor, &ctx, &account)
|
||||||
|
.await
|
||||||
|
.map_err(|error| oauth_error_to_local_refresh_error(provider_type, error))?;
|
||||||
|
|
||||||
let expires_at_unix_secs = resolve_expires_at(payload.get("expires_in"));
|
|
||||||
metadata.insert(
|
|
||||||
"provider_type".to_string(),
|
|
||||||
Value::String(template.provider_type.to_string()),
|
|
||||||
);
|
|
||||||
metadata.insert("updated_at".to_string(), json!(current_unix_secs()));
|
|
||||||
let response_refresh_token = payload.get("refresh_token").and_then(non_empty_string);
|
|
||||||
let response_refresh_token_fingerprint = response_refresh_token
|
|
||||||
.as_deref()
|
|
||||||
.map(secret_fingerprint)
|
|
||||||
.unwrap_or_else(|| "-".to_string());
|
|
||||||
let response_refresh_token_rotated = response_refresh_token
|
|
||||||
.as_deref()
|
|
||||||
.map(|value| value != refresh_token.as_str());
|
|
||||||
if let Some(refresh_token) = response_refresh_token.as_ref() {
|
|
||||||
metadata.insert(
|
|
||||||
"refresh_token".to_string(),
|
|
||||||
Value::String(refresh_token.clone()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Some(token_type) = payload.get("token_type").and_then(non_empty_string) {
|
|
||||||
metadata.insert("token_type".to_string(), Value::String(token_type));
|
|
||||||
}
|
|
||||||
if let Some(scope) = payload.get("scope").and_then(non_empty_string) {
|
|
||||||
metadata.insert("scope".to_string(), Value::String(scope));
|
|
||||||
}
|
|
||||||
match expires_at_unix_secs {
|
|
||||||
Some(expires_at_unix_secs) => {
|
|
||||||
metadata.insert("expires_at".to_string(), json!(expires_at_unix_secs));
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
metadata.remove("expires_at");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let stored_refresh_token_fingerprint = metadata
|
|
||||||
.get("refresh_token")
|
|
||||||
.and_then(non_empty_string)
|
|
||||||
.map(|value| secret_fingerprint(value.as_str()))
|
|
||||||
.unwrap_or_else(|| "-".to_string());
|
|
||||||
let stored_refresh_token_source = if response_refresh_token.is_some() {
|
|
||||||
"response"
|
|
||||||
} else {
|
|
||||||
"existing"
|
|
||||||
};
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
key_id = %transport.key.id,
|
key_id = %transport.key.id,
|
||||||
provider_id = %transport.provider.id,
|
provider_id = %transport.provider.id,
|
||||||
endpoint_id = %transport.endpoint.id,
|
endpoint_id = %transport.endpoint.id,
|
||||||
provider_type = template.provider_type,
|
provider_type,
|
||||||
status_code = status.as_u16(),
|
expires_at_unix_secs = ?refreshed.token_set.expires_at_unix_secs,
|
||||||
request_refresh_token_fingerprint = %request_refresh_token_fingerprint,
|
response_has_refresh_token = refreshed.token_set.refresh_token.is_some(),
|
||||||
response_has_refresh_token = response_refresh_token.is_some(),
|
|
||||||
response_refresh_token_fingerprint = %response_refresh_token_fingerprint,
|
|
||||||
response_refresh_token_rotated = ?response_refresh_token_rotated,
|
|
||||||
stored_refresh_token_source = stored_refresh_token_source,
|
|
||||||
stored_refresh_token_fingerprint = %stored_refresh_token_fingerprint,
|
|
||||||
expires_at_unix_secs = ?expires_at_unix_secs,
|
|
||||||
"gateway generic oauth refresh succeeded"
|
"gateway generic oauth refresh succeeded"
|
||||||
);
|
);
|
||||||
if response_refresh_token.is_none() && template.provider_type == "codex" {
|
|
||||||
tracing::warn!(
|
|
||||||
key_id = %transport.key.id,
|
|
||||||
provider_id = %transport.provider.id,
|
|
||||||
endpoint_id = %transport.endpoint.id,
|
|
||||||
provider_type = template.provider_type,
|
|
||||||
request_refresh_token_fingerprint = %request_refresh_token_fingerprint,
|
|
||||||
stored_refresh_token_fingerprint = %stored_refresh_token_fingerprint,
|
|
||||||
"gateway codex oauth refresh succeeded without replacement refresh_token"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Some(self.build_cached_entry(
|
Ok(Some(Self::build_cached_entry(provider_type, refreshed)))
|
||||||
template,
|
|
||||||
access_token.as_str(),
|
|
||||||
Value::Object(metadata),
|
|
||||||
expires_at_unix_secs,
|
|
||||||
)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn template_for_provider_type(provider_type: &str) -> Option<GenericOAuthTemplate> {
|
fn generic_provider_type(provider_type: &str) -> Option<&'static str> {
|
||||||
let normalized = provider_type.trim();
|
let normalized = provider_type.trim();
|
||||||
GENERIC_OAUTH_TEMPLATES
|
GENERIC_PROVIDER_OAUTH_TEMPLATES
|
||||||
.iter()
|
.iter()
|
||||||
.find(|template| normalized.eq_ignore_ascii_case(template.provider_type))
|
.find(|template| normalized.eq_ignore_ascii_case(template.provider_type))
|
||||||
.copied()
|
.map(|template| template.provider_type)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn refresh_token_from_auth_config(auth_config: &Value) -> Option<String> {
|
fn refresh_token_from_auth_config(auth_config: &Value) -> Option<String> {
|
||||||
@@ -529,27 +295,26 @@ fn refresh_token_from_auth_config(auth_config: &Value) -> Option<String> {
|
|||||||
.and_then(non_empty_string)
|
.and_then(non_empty_string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn auth_config_expires_at(auth_config: &Value) -> Option<u64> {
|
||||||
|
auth_config
|
||||||
|
.as_object()
|
||||||
|
.and_then(|object| object.get("expires_at"))
|
||||||
|
.and_then(|value| parse_u64_value(Some(value)))
|
||||||
|
}
|
||||||
|
|
||||||
fn auth_config_expires_soon(auth_config: Option<&Value>) -> bool {
|
fn auth_config_expires_soon(auth_config: Option<&Value>) -> bool {
|
||||||
expires_at_requires_refresh(
|
expires_at_requires_refresh(auth_config.and_then(auth_config_expires_at))
|
||||||
auth_config
|
|
||||||
.and_then(|value| value.as_object())
|
|
||||||
.and_then(|object| object.get("expires_at"))
|
|
||||||
.and_then(|value| parse_u64_value(Some(value))),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn expires_at_requires_refresh(expires_at_unix_secs: Option<u64>) -> bool {
|
fn expires_at_requires_refresh(expires_at_unix_secs: Option<u64>) -> bool {
|
||||||
expires_at_unix_secs
|
expires_at_unix_secs
|
||||||
.map(|expires_at_unix_secs| {
|
.map(|expires_at_unix_secs| {
|
||||||
current_unix_secs() >= expires_at_unix_secs.saturating_sub(OAUTH_REFRESH_SKEW_SECS)
|
aether_oauth::core::current_unix_secs()
|
||||||
|
>= expires_at_unix_secs.saturating_sub(OAUTH_REFRESH_SKEW_SECS)
|
||||||
})
|
})
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_expires_at(expires_in: Option<&Value>) -> Option<u64> {
|
|
||||||
parse_u64_value(expires_in).map(|expires_in| current_unix_secs().saturating_add(expires_in))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_u64_value(value: Option<&Value>) -> Option<u64> {
|
fn parse_u64_value(value: Option<&Value>) -> Option<u64> {
|
||||||
match value? {
|
match value? {
|
||||||
Value::Number(number) => number.as_u64(),
|
Value::Number(number) => number.as_u64(),
|
||||||
@@ -566,28 +331,22 @@ fn non_empty_string(value: &Value) -> Option<String> {
|
|||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn secret_fingerprint(value: &str) -> String {
|
fn current_access_token(
|
||||||
let digest = Sha256::digest(value.as_bytes());
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
let mut fingerprint = String::with_capacity(16);
|
entry: Option<&CachedOAuthEntry>,
|
||||||
for byte in digest.iter().take(8) {
|
) -> Option<String> {
|
||||||
use std::fmt::Write as _;
|
entry
|
||||||
let _ = write!(&mut fingerprint, "{byte:02x}");
|
.and_then(|entry| {
|
||||||
}
|
entry
|
||||||
fingerprint
|
.auth_header_value
|
||||||
}
|
.trim()
|
||||||
|
.strip_prefix("Bearer ")
|
||||||
fn current_unix_secs() -> u64 {
|
.map(str::trim)
|
||||||
SystemTime::now()
|
.filter(|value| !value.is_empty())
|
||||||
.duration_since(UNIX_EPOCH)
|
.map(ToOwned::to_owned)
|
||||||
.ok()
|
})
|
||||||
.map(|value| value.as_secs())
|
.or_else(|| {
|
||||||
.unwrap_or_default()
|
let secret = transport.key.decrypted_api_key.trim();
|
||||||
}
|
(!secret.is_empty() && secret != PLACEHOLDER_API_KEY).then(|| secret.to_string())
|
||||||
|
})
|
||||||
fn truncate_body(body: &str) -> String {
|
|
||||||
let body = body.trim();
|
|
||||||
if body.is_empty() {
|
|
||||||
return String::from("-");
|
|
||||||
}
|
|
||||||
body.chars().take(500).collect()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use aether_oauth::provider::providers::KiroProviderOAuthAdapter as CoreKiroProviderOAuthAdapter;
|
||||||
|
use aether_oauth::provider::{ProviderOAuthAccount, ProviderOAuthAdapter};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
use super::super::oauth_refresh::{
|
use super::super::oauth_refresh::{
|
||||||
CachedOAuthEntry, LocalOAuthHttpExecutor, LocalOAuthHttpRequest, LocalOAuthRefreshAdapter,
|
oauth_error_to_local_refresh_error, provider_oauth_transport_context_from_snapshot,
|
||||||
LocalOAuthRefreshError, LocalResolvedOAuthRequestAuth,
|
CachedOAuthEntry, LocalOAuthHttpExecutor, LocalOAuthRefreshAdapter, LocalOAuthRefreshError,
|
||||||
|
LocalResolvedOAuthRequestAuth, ProviderOAuthLocalHttpExecutor,
|
||||||
};
|
};
|
||||||
use super::super::snapshot::GatewayProviderTransportSnapshot;
|
use super::super::snapshot::GatewayProviderTransportSnapshot;
|
||||||
use super::auth::{
|
use super::auth::{
|
||||||
build_kiro_request_auth_from_config, resolve_local_kiro_request_auth, PROVIDER_TYPE,
|
build_kiro_request_auth_from_config, resolve_local_kiro_request_auth, PROVIDER_TYPE,
|
||||||
};
|
};
|
||||||
use super::credentials::{generate_machine_id, KiroAuthConfig};
|
use super::credentials::KiroAuthConfig;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
const IDC_AMZ_USER_AGENT: &str = "aws-sdk-js/3.738.0 ua/2.1 os/other lang/js md/browser#unknown_unknown api/sso-oidc#3.738.0 m/E KiroIDE";
|
const IDC_AMZ_USER_AGENT: &str = "aws-sdk-js/3.738.0 ua/2.1 os/other lang/js md/browser#unknown_unknown api/sso-oidc#3.738.0 m/E KiroIDE";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
@@ -38,39 +41,33 @@ impl KiroOAuthRefreshAdapter {
|
|||||||
transport: &GatewayProviderTransportSnapshot,
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
auth_config: &KiroAuthConfig,
|
auth_config: &KiroAuthConfig,
|
||||||
) -> Result<KiroAuthConfig, LocalOAuthRefreshError> {
|
) -> Result<KiroAuthConfig, LocalOAuthRefreshError> {
|
||||||
if auth_config.is_idc_auth() {
|
let adapter = CoreKiroProviderOAuthAdapter::default().with_refresh_base_urls(
|
||||||
self.refresh_idc_token(executor, transport, auth_config)
|
self.social_refresh_base_url.clone(),
|
||||||
.await
|
self.idc_refresh_base_url.clone(),
|
||||||
} else {
|
);
|
||||||
self.refresh_social_token(executor, transport, auth_config)
|
let oauth_executor =
|
||||||
.await
|
ProviderOAuthLocalHttpExecutor::new(PROVIDER_TYPE, transport, executor);
|
||||||
}
|
let ctx = provider_oauth_transport_context_from_snapshot(transport);
|
||||||
}
|
let account = ProviderOAuthAccount {
|
||||||
|
provider_type: PROVIDER_TYPE.to_string(),
|
||||||
fn social_refresh_url(&self, auth_config: &KiroAuthConfig) -> String {
|
access_token: auth_config
|
||||||
if let Some(base_url) = self
|
.cached_access_token()
|
||||||
.social_refresh_base_url
|
.map(ToOwned::to_owned)
|
||||||
.as_deref()
|
.unwrap_or_default(),
|
||||||
.map(str::trim)
|
auth_config: auth_config.to_json_value(),
|
||||||
.filter(|value| !value.is_empty())
|
expires_at_unix_secs: auth_config.expires_at,
|
||||||
{
|
identity: BTreeMap::new(),
|
||||||
return format!("{}/refreshToken", base_url.trim_end_matches('/'));
|
};
|
||||||
}
|
let refreshed = adapter
|
||||||
let region = auth_config.effective_auth_region();
|
.refresh(&oauth_executor, &ctx, &account)
|
||||||
format!("https://prod.{region}.auth.desktop.kiro.dev/refreshToken")
|
.await
|
||||||
}
|
.map_err(|error| oauth_error_to_local_refresh_error(PROVIDER_TYPE, error))?;
|
||||||
|
KiroAuthConfig::from_json_value(&refreshed.auth_config).ok_or_else(|| {
|
||||||
fn idc_refresh_url(&self, auth_config: &KiroAuthConfig) -> String {
|
LocalOAuthRefreshError::InvalidResponse {
|
||||||
if let Some(base_url) = self
|
provider_type: PROVIDER_TYPE,
|
||||||
.idc_refresh_base_url
|
message: "kiro refresh returned invalid auth_config".to_string(),
|
||||||
.as_deref()
|
}
|
||||||
.map(str::trim)
|
})
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
{
|
|
||||||
return format!("{}/token", base_url.trim_end_matches('/'));
|
|
||||||
}
|
|
||||||
let region = auth_config.effective_auth_region();
|
|
||||||
format!("https://oidc.{region}.amazonaws.com/token")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn auth_config_from_entry(entry: &CachedOAuthEntry) -> Option<KiroAuthConfig> {
|
fn auth_config_from_entry(entry: &CachedOAuthEntry) -> Option<KiroAuthConfig> {
|
||||||
@@ -102,222 +99,6 @@ impl KiroOAuthRefreshAdapter {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn refresh_social_token(
|
|
||||||
&self,
|
|
||||||
executor: &dyn LocalOAuthHttpExecutor,
|
|
||||||
transport: &GatewayProviderTransportSnapshot,
|
|
||||||
auth_config: &KiroAuthConfig,
|
|
||||||
) -> Result<KiroAuthConfig, LocalOAuthRefreshError> {
|
|
||||||
let url = self.social_refresh_url(auth_config);
|
|
||||||
let host = reqwest::Url::parse(&url)
|
|
||||||
.ok()
|
|
||||||
.and_then(|value| value.host_str().map(ToOwned::to_owned))
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"prod.{}.auth.desktop.kiro.dev",
|
|
||||||
auth_config.effective_auth_region()
|
|
||||||
)
|
|
||||||
});
|
|
||||||
let machine_id = generate_machine_id(auth_config, None).ok_or_else(|| {
|
|
||||||
LocalOAuthRefreshError::InvalidResponse {
|
|
||||||
provider_type: PROVIDER_TYPE,
|
|
||||||
message: "missing machine_id seed for social refresh".to_string(),
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
let kiro_version = auth_config.effective_kiro_version();
|
|
||||||
let user_agent = build_kiro_ide_tag(kiro_version, &machine_id);
|
|
||||||
let response = executor
|
|
||||||
.execute(
|
|
||||||
PROVIDER_TYPE,
|
|
||||||
transport,
|
|
||||||
&LocalOAuthHttpRequest {
|
|
||||||
request_id: "provider-oauth:kiro-social-refresh",
|
|
||||||
method: reqwest::Method::POST,
|
|
||||||
url,
|
|
||||||
headers: std::collections::BTreeMap::from([
|
|
||||||
("user-agent".to_string(), user_agent),
|
|
||||||
("host".to_string(), host),
|
|
||||||
(
|
|
||||||
"accept".to_string(),
|
|
||||||
"application/json, text/plain, */*".to_string(),
|
|
||||||
),
|
|
||||||
("content-type".to_string(), "application/json".to_string()),
|
|
||||||
("connection".to_string(), "close".to_string()),
|
|
||||||
(
|
|
||||||
"accept-encoding".to_string(),
|
|
||||||
"gzip, compress, deflate, br".to_string(),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
json_body: Some(json!({
|
|
||||||
"refreshToken": auth_config
|
|
||||||
.refresh_token
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.unwrap_or_default()
|
|
||||||
})),
|
|
||||||
body_bytes: None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let status = reqwest::StatusCode::from_u16(response.status_code).unwrap_or_default();
|
|
||||||
let body = response.body_text;
|
|
||||||
if !status.is_success() {
|
|
||||||
return Err(LocalOAuthRefreshError::HttpStatus {
|
|
||||||
provider_type: PROVIDER_TYPE,
|
|
||||||
status_code: status.as_u16(),
|
|
||||||
body_excerpt: truncate_body(&body),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload: Value =
|
|
||||||
serde_json::from_str(&body).map_err(|_| LocalOAuthRefreshError::InvalidResponse {
|
|
||||||
provider_type: PROVIDER_TYPE,
|
|
||||||
message: "social refresh returned non-json body".to_string(),
|
|
||||||
})?;
|
|
||||||
let access_token = payload
|
|
||||||
.get("accessToken")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.ok_or_else(|| LocalOAuthRefreshError::InvalidResponse {
|
|
||||||
provider_type: PROVIDER_TYPE,
|
|
||||||
message: "social refresh returned empty accessToken".to_string(),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut refreshed = auth_config.clone();
|
|
||||||
refreshed.access_token = Some(access_token.to_string());
|
|
||||||
refreshed.expires_at = Some(resolve_expires_at(&payload));
|
|
||||||
if refreshed
|
|
||||||
.machine_id
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.is_none_or(|value| value.is_empty())
|
|
||||||
{
|
|
||||||
refreshed.machine_id = Some(machine_id);
|
|
||||||
}
|
|
||||||
if let Some(refresh_token) = payload
|
|
||||||
.get("refreshToken")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
{
|
|
||||||
refreshed.refresh_token = Some(refresh_token.to_string());
|
|
||||||
}
|
|
||||||
if let Some(profile_arn) = payload
|
|
||||||
.get("profileArn")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
{
|
|
||||||
refreshed.profile_arn = Some(profile_arn.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(refreshed)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn refresh_idc_token(
|
|
||||||
&self,
|
|
||||||
executor: &dyn LocalOAuthHttpExecutor,
|
|
||||||
transport: &GatewayProviderTransportSnapshot,
|
|
||||||
auth_config: &KiroAuthConfig,
|
|
||||||
) -> Result<KiroAuthConfig, LocalOAuthRefreshError> {
|
|
||||||
let url = self.idc_refresh_url(auth_config);
|
|
||||||
let host = reqwest::Url::parse(&url)
|
|
||||||
.ok()
|
|
||||||
.and_then(|value| value.host_str().map(ToOwned::to_owned))
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
format!("oidc.{}.amazonaws.com", auth_config.effective_auth_region())
|
|
||||||
});
|
|
||||||
let response = executor
|
|
||||||
.execute(
|
|
||||||
PROVIDER_TYPE,
|
|
||||||
transport,
|
|
||||||
&LocalOAuthHttpRequest {
|
|
||||||
request_id: "provider-oauth:kiro-idc-refresh",
|
|
||||||
method: reqwest::Method::POST,
|
|
||||||
url,
|
|
||||||
headers: std::collections::BTreeMap::from([
|
|
||||||
("content-type".to_string(), "application/json".to_string()),
|
|
||||||
("host".to_string(), host),
|
|
||||||
(
|
|
||||||
"x-amz-user-agent".to_string(),
|
|
||||||
IDC_AMZ_USER_AGENT.to_string(),
|
|
||||||
),
|
|
||||||
("user-agent".to_string(), "node".to_string()),
|
|
||||||
("accept".to_string(), "*/*".to_string()),
|
|
||||||
]),
|
|
||||||
json_body: Some(json!({
|
|
||||||
"clientId": auth_config
|
|
||||||
.client_id
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.unwrap_or_default(),
|
|
||||||
"clientSecret": auth_config
|
|
||||||
.client_secret
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.unwrap_or_default(),
|
|
||||||
"refreshToken": auth_config
|
|
||||||
.refresh_token
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.unwrap_or_default(),
|
|
||||||
"grantType": "refresh_token"
|
|
||||||
})),
|
|
||||||
body_bytes: None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let status = reqwest::StatusCode::from_u16(response.status_code).unwrap_or_default();
|
|
||||||
let body = response.body_text;
|
|
||||||
if !status.is_success() {
|
|
||||||
return Err(LocalOAuthRefreshError::HttpStatus {
|
|
||||||
provider_type: PROVIDER_TYPE,
|
|
||||||
status_code: status.as_u16(),
|
|
||||||
body_excerpt: truncate_body(&body),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload: Value =
|
|
||||||
serde_json::from_str(&body).map_err(|_| LocalOAuthRefreshError::InvalidResponse {
|
|
||||||
provider_type: PROVIDER_TYPE,
|
|
||||||
message: "idc refresh returned non-json body".to_string(),
|
|
||||||
})?;
|
|
||||||
let access_token = payload
|
|
||||||
.get("accessToken")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.ok_or_else(|| LocalOAuthRefreshError::InvalidResponse {
|
|
||||||
provider_type: PROVIDER_TYPE,
|
|
||||||
message: "idc refresh returned empty accessToken".to_string(),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut refreshed = auth_config.clone();
|
|
||||||
refreshed.access_token = Some(access_token.to_string());
|
|
||||||
refreshed.expires_at = Some(resolve_expires_at(&payload));
|
|
||||||
if refreshed
|
|
||||||
.machine_id
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.is_none_or(|value| value.is_empty())
|
|
||||||
{
|
|
||||||
refreshed.machine_id = generate_machine_id(auth_config, None);
|
|
||||||
}
|
|
||||||
if let Some(refresh_token) = payload
|
|
||||||
.get("refreshToken")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
{
|
|
||||||
refreshed.refresh_token = Some(refresh_token.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(refreshed)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn refreshable_auth_config(
|
fn refreshable_auth_config(
|
||||||
&self,
|
&self,
|
||||||
transport: &GatewayProviderTransportSnapshot,
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
@@ -374,53 +155,13 @@ impl LocalOAuthRefreshAdapter for KiroOAuthRefreshAdapter {
|
|||||||
let Some(auth_config) = self.refreshable_auth_config(transport, entry) else {
|
let Some(auth_config) = self.refreshable_auth_config(transport, entry) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let refreshed = if auth_config.is_idc_auth() {
|
let refreshed = self
|
||||||
self.refresh_idc_token(executor, transport, &auth_config)
|
.refresh_auth_config(executor, transport, &auth_config)
|
||||||
.await?
|
.await?;
|
||||||
} else {
|
|
||||||
self.refresh_social_token(executor, transport, &auth_config)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
Ok(Self::build_cached_entry(&refreshed))
|
Ok(Self::build_cached_entry(&refreshed))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_kiro_ide_tag(kiro_version: &str, machine_id: &str) -> String {
|
|
||||||
if machine_id.trim().is_empty() {
|
|
||||||
format!("KiroIDE-{kiro_version}")
|
|
||||||
} else {
|
|
||||||
format!("KiroIDE-{kiro_version}-{machine_id}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_expires_at(payload: &Value) -> u64 {
|
|
||||||
let expires_in = payload
|
|
||||||
.get("expiresIn")
|
|
||||||
.and_then(|value| {
|
|
||||||
value
|
|
||||||
.as_u64()
|
|
||||||
.or_else(|| value.as_str()?.parse::<u64>().ok())
|
|
||||||
})
|
|
||||||
.unwrap_or(3600);
|
|
||||||
current_unix_secs().saturating_add(expires_in)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn current_unix_secs() -> u64 {
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.ok()
|
|
||||||
.map(|value| value.as_secs())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn truncate_body(body: &str) -> String {
|
|
||||||
let body = body.trim();
|
|
||||||
if body.is_empty() {
|
|
||||||
return String::from("-");
|
|
||||||
}
|
|
||||||
body.chars().take(500).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub mod url;
|
|||||||
pub mod vertex;
|
pub mod vertex;
|
||||||
mod video;
|
mod video;
|
||||||
|
|
||||||
|
pub use aether_oauth as oauth;
|
||||||
pub use auth::{build_passthrough_headers, ensure_upstream_auth_header};
|
pub use auth::{build_passthrough_headers, ensure_upstream_auth_header};
|
||||||
pub use cache::{provider_transport_snapshot_looks_refreshed, ProviderTransportSnapshotCacheKey};
|
pub use cache::{provider_transport_snapshot_looks_refreshed, ProviderTransportSnapshotCacheKey};
|
||||||
pub use generic_oauth::{
|
pub use generic_oauth::{
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ use std::fmt;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use aether_data::redis::{RedisLockKey, RedisLockRunner};
|
use aether_data::redis::{RedisLockKey, RedisLockRunner};
|
||||||
|
use aether_oauth::core::OAuthError;
|
||||||
|
use aether_oauth::network::{
|
||||||
|
OAuthHttpExecutor, OAuthHttpRequest, OAuthHttpResponse, OAuthNetworkContext,
|
||||||
|
};
|
||||||
|
use aether_oauth::provider::ProviderOAuthTransportContext;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
@@ -72,6 +77,11 @@ pub enum LocalOAuthRefreshError {
|
|||||||
status_code: u16,
|
status_code: u16,
|
||||||
body_excerpt: String,
|
body_excerpt: String,
|
||||||
},
|
},
|
||||||
|
#[error("{provider_type} oauth refresh transport failed: {message}")]
|
||||||
|
TransportMessage {
|
||||||
|
provider_type: &'static str,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
#[error("{provider_type} oauth refresh returned invalid response: {message}")]
|
#[error("{provider_type} oauth refresh returned invalid response: {message}")]
|
||||||
InvalidResponse {
|
InvalidResponse {
|
||||||
provider_type: &'static str,
|
provider_type: &'static str,
|
||||||
@@ -144,6 +154,127 @@ impl LocalOAuthHttpExecutor for ReqwestLocalOAuthHttpExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) struct ProviderOAuthLocalHttpExecutor<'a> {
|
||||||
|
provider_type: &'static str,
|
||||||
|
transport: &'a GatewayProviderTransportSnapshot,
|
||||||
|
inner: &'a dyn LocalOAuthHttpExecutor,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> ProviderOAuthLocalHttpExecutor<'a> {
|
||||||
|
pub(crate) fn new(
|
||||||
|
provider_type: &'static str,
|
||||||
|
transport: &'a GatewayProviderTransportSnapshot,
|
||||||
|
inner: &'a dyn LocalOAuthHttpExecutor,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
provider_type,
|
||||||
|
transport,
|
||||||
|
inner,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl OAuthHttpExecutor for ProviderOAuthLocalHttpExecutor<'_> {
|
||||||
|
async fn execute(&self, request: OAuthHttpRequest) -> Result<OAuthHttpResponse, OAuthError> {
|
||||||
|
let response = self
|
||||||
|
.inner
|
||||||
|
.execute(
|
||||||
|
self.provider_type,
|
||||||
|
self.transport,
|
||||||
|
&LocalOAuthHttpRequest {
|
||||||
|
request_id: "provider-oauth:local-refresh-token",
|
||||||
|
method: request.method,
|
||||||
|
url: request.url,
|
||||||
|
headers: request.headers,
|
||||||
|
json_body: request.json_body,
|
||||||
|
body_bytes: request.body_bytes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(local_refresh_error_to_oauth_error)?;
|
||||||
|
let json_body = serde_json::from_str::<Value>(&response.body_text).ok();
|
||||||
|
Ok(OAuthHttpResponse {
|
||||||
|
status_code: response.status_code,
|
||||||
|
body_text: response.body_text,
|
||||||
|
json_body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn provider_oauth_transport_context_from_snapshot(
|
||||||
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
|
) -> ProviderOAuthTransportContext {
|
||||||
|
ProviderOAuthTransportContext {
|
||||||
|
provider_id: transport.provider.id.clone(),
|
||||||
|
provider_type: transport.provider.provider_type.clone(),
|
||||||
|
endpoint_id: Some(transport.endpoint.id.clone()),
|
||||||
|
key_id: Some(transport.key.id.clone()),
|
||||||
|
auth_type: Some(transport.key.auth_type.clone()),
|
||||||
|
decrypted_api_key: Some(transport.key.decrypted_api_key.clone()),
|
||||||
|
decrypted_auth_config: transport.key.decrypted_auth_config.clone(),
|
||||||
|
provider_config: transport.provider.config.clone(),
|
||||||
|
endpoint_config: transport.endpoint.config.clone(),
|
||||||
|
key_config: None,
|
||||||
|
network: OAuthNetworkContext::provider_operation(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn oauth_error_to_local_refresh_error(
|
||||||
|
provider_type: &'static str,
|
||||||
|
error: OAuthError,
|
||||||
|
) -> LocalOAuthRefreshError {
|
||||||
|
match error {
|
||||||
|
OAuthError::HttpStatus {
|
||||||
|
status_code,
|
||||||
|
body_excerpt,
|
||||||
|
} => LocalOAuthRefreshError::HttpStatus {
|
||||||
|
provider_type,
|
||||||
|
status_code,
|
||||||
|
body_excerpt,
|
||||||
|
},
|
||||||
|
OAuthError::Transport(message) => LocalOAuthRefreshError::TransportMessage {
|
||||||
|
provider_type,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
OAuthError::InvalidRequest(message)
|
||||||
|
| OAuthError::InvalidResponse(message)
|
||||||
|
| OAuthError::Storage(message)
|
||||||
|
| OAuthError::UnsupportedProvider(message) => LocalOAuthRefreshError::InvalidResponse {
|
||||||
|
provider_type,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
OAuthError::InvalidState => LocalOAuthRefreshError::InvalidResponse {
|
||||||
|
provider_type,
|
||||||
|
message: "oauth state is invalid or expired".to_string(),
|
||||||
|
},
|
||||||
|
OAuthError::EncryptionUnavailable => LocalOAuthRefreshError::InvalidResponse {
|
||||||
|
provider_type,
|
||||||
|
message: "oauth encryption unavailable".to_string(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_refresh_error_to_oauth_error(error: LocalOAuthRefreshError) -> OAuthError {
|
||||||
|
match error {
|
||||||
|
LocalOAuthRefreshError::Transport { source, .. } => {
|
||||||
|
OAuthError::Transport(source.to_string())
|
||||||
|
}
|
||||||
|
LocalOAuthRefreshError::TransportMessage { message, .. } => OAuthError::Transport(message),
|
||||||
|
LocalOAuthRefreshError::HttpStatus {
|
||||||
|
status_code,
|
||||||
|
body_excerpt,
|
||||||
|
..
|
||||||
|
} => OAuthError::HttpStatus {
|
||||||
|
status_code,
|
||||||
|
body_excerpt,
|
||||||
|
},
|
||||||
|
LocalOAuthRefreshError::InvalidResponse { message, .. } => {
|
||||||
|
OAuthError::InvalidResponse(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait LocalOAuthRefreshAdapter: Send + Sync {
|
pub trait LocalOAuthRefreshAdapter: Send + Sync {
|
||||||
fn provider_type(&self) -> &'static str;
|
fn provider_type(&self) -> &'static str;
|
||||||
|
|||||||
Reference in New Issue
Block a user