#![allow(non_snake_case)]
use serde::{Deserialize, Serialize};
use std::collections::HashMap as FxHashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
#[cfg(feature = "pyo3")]
use tokio::runtime::Runtime;
#[cfg(feature = "pyo3")]
use pyo3::PyResult;
#[cfg(feature = "auth")]
extern crate urlencoding;
#[cfg_attr(feature = "pyo3", pyo3::prelude::pyclass(get_all, set_all))]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RiOAuthProvider {
pub id: String,
pub name: String,
pub client_id: String,
pub client_secret: String,
pub auth_url: String,
pub token_url: String,
pub user_info_url: String,
pub scopes: Vec<String>,
pub enabled: bool,
pub redirect_uri: Option<String>,
pub allowed_redirect_uris: Vec<String>,
}
#[cfg(feature = "pyo3")]
#[pyo3::prelude::pymethods]
impl RiOAuthProvider {
#[new]
fn py_new(
id: String,
name: String,
client_id: String,
client_secret: String,
auth_url: String,
token_url: String,
user_info_url: String,
scopes: Vec<String>,
enabled: bool,
redirect_uri: Option<String>,
allowed_redirect_uris: Option<Vec<String>>,
) -> Self {
Self {
id,
name,
client_id,
client_secret,
auth_url,
token_url,
user_info_url,
scopes,
enabled,
redirect_uri,
allowed_redirect_uris: allowed_redirect_uris.unwrap_or_default(),
}
}
}
#[cfg_attr(feature = "pyo3", pyo3::prelude::pyclass)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RiOAuthToken {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_in: Option<i64>,
pub token_type: String,
pub scope: Option<String>,
}
#[cfg(feature = "pyo3")]
#[pyo3::prelude::pymethods]
impl RiOAuthToken {
#[new]
fn py_new(
access_token: String,
token_type: String,
refresh_token: Option<String>,
scope: Option<String>,
expires_in: Option<i64>,
) -> Self {
Self {
access_token,
token_type,
refresh_token,
scope,
expires_in,
}
}
}
#[cfg_attr(feature = "pyo3", pyo3::prelude::pyclass(get_all, set_all))]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RiOAuthUserInfo {
pub id: String,
pub email: String,
pub name: Option<String>,
pub avatar_url: Option<String>,
pub provider: String,
}
#[cfg(feature = "pyo3")]
#[pyo3::prelude::pymethods]
impl RiOAuthUserInfo {
#[new]
fn py_new(
id: String,
email: String,
name: Option<String>,
avatar_url: Option<String>,
provider: String,
) -> Self {
Self {
id,
email,
name,
avatar_url,
provider,
}
}
}
#[cfg_attr(feature = "pyo3", pyo3::prelude::pyclass)]
pub struct RiOAuthManager {
providers: RwLock<FxHashMap<String, RiOAuthProvider>>,
_token_cache: Arc<dyn crate::cache::RiCache>,
}
impl RiOAuthManager {
pub fn new(cache: Arc<dyn crate::cache::RiCache>) -> Self {
Self {
providers: RwLock::new(FxHashMap::default()),
_token_cache: cache,
}
}
#[allow(dead_code)]
fn is_redirect_uri_allowed(provider: &RiOAuthProvider, redirect_uri: &str) -> bool {
if provider.allowed_redirect_uris.is_empty() {
let default_uri = provider.redirect_uri.as_deref()
.unwrap_or("http://localhost:8080/auth/callback");
return redirect_uri == default_uri;
}
for allowed in &provider.allowed_redirect_uris {
if redirect_uri == allowed {
return true;
}
if allowed.ends_with('*') {
let prefix = &allowed[..allowed.len()-1];
if redirect_uri.starts_with(prefix) {
return true;
}
}
}
false
}
pub async fn register_provider(&self, provider: RiOAuthProvider) -> crate::core::RiResult<()> {
let mut providers = self.providers.write().await;
providers.insert(provider.id.clone(), provider);
Ok(())
}
pub async fn get_provider(&self, provider_id: &str) -> crate::core::RiResult<Option<RiOAuthProvider>> {
let providers = self.providers.read().await;
Ok(providers.get(provider_id).cloned())
}
#[cfg(feature = "auth")]
pub async fn get_auth_url(&self, provider_id: &str, state: &str) -> crate::core::RiResult<Option<String>> {
if state.is_empty() || state.len() > 128 {
return Err(crate::core::RiError::Other("Invalid state parameter: must be 1-128 characters".to_string()));
}
for c in state.chars() {
if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
return Err(crate::core::RiError::Other("Invalid state parameter: only alphanumeric, dash, and underscore allowed".to_string()));
}
}
let providers = self.providers.read().await;
if let Some(provider) = providers.get(provider_id) {
if !provider.enabled {
return Ok(None);
}
let scope = provider.scopes.join(" ");
let encoded_scope = urlencoding::encode(&scope);
let redirect_uri = provider.redirect_uri.as_deref()
.unwrap_or("http://localhost:8080/auth/callback");
let auth_url = format!(
"{}?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}",
provider.auth_url,
urlencoding::encode(&provider.client_id),
urlencoding::encode(redirect_uri),
encoded_scope,
urlencoding::encode(state)
);
Ok(Some(auth_url))
} else {
Ok(None)
}
}
#[cfg(not(feature = "auth"))]
pub async fn get_auth_url(&self, _provider_id: &str, _state: &str) -> crate::core::RiResult<Option<String>> {
Err(crate::core::RiError::Other("Auth feature is not enabled. Enable the 'auth' feature to use OAuth functionality.".to_string()))
}
#[cfg(feature = "http_client")]
pub async fn exchange_code_for_token(
&self,
provider_id: &str,
code: &str,
redirect_uri: &str,
) -> crate::core::RiResult<Option<RiOAuthToken>> {
let providers = self.providers.read().await;
if let Some(provider) = providers.get(provider_id) {
if !provider.enabled {
return Ok(None);
}
if !Self::is_redirect_uri_allowed(provider, redirect_uri) {
log::warn!(
"[Ri.OAuth] Redirect URI not allowed: {} for provider {}",
redirect_uri, provider_id
);
return Err(crate::core::RiError::Other(
"Invalid redirect URI: not in allowed list".to_string()
));
}
if code.is_empty() || code.len() > 1024 {
return Err(crate::core::RiError::Other(
"Invalid authorization code: must be 1-1024 characters".to_string()
));
}
let client = reqwest::Client::new();
let params = [
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("client_id", &provider.client_id),
("client_secret", &provider.client_secret),
];
let response = client
.post(&provider.token_url)
.form(¶ms)
.send()
.await
.map_err(|e| crate::core::RiError::ExternalError(e.to_string()))?;
if response.status().is_success() {
let token_data: serde_json::Value = response.json().await
.map_err(|e| crate::core::RiError::ExternalError(e.to_string()))?;
let token = RiOAuthToken {
access_token: token_data["access_token"]
.as_str()
.ok_or_else(|| crate::core::RiError::ExternalError("Missing access_token".to_string()))?
.to_string(),
refresh_token: token_data["refresh_token"].as_str().map(String::from),
expires_in: token_data["expires_in"].as_i64(),
token_type: token_data["token_type"]
.as_str()
.unwrap_or("Bearer")
.to_string(),
scope: token_data["scope"].as_str().map(String::from),
};
Ok(Some(token))
} else {
Ok(None)
}
} else {
Ok(None)
}
}
#[cfg(not(feature = "http_client"))]
pub async fn exchange_code_for_token(
&self,
_provider_id: &str,
_code: &str,
_redirect_uri: &str,
) -> crate::core::RiResult<Option<RiOAuthToken>> {
Err(crate::core::RiError::Other("HTTP client is not enabled. Enable the 'http_client' feature to use OAuth functionality.".to_string()))
}
#[cfg(feature = "http_client")]
pub async fn get_user_info(
&self,
provider_id: &str,
access_token: &str,
) -> crate::core::RiResult<Option<RiOAuthUserInfo>> {
let providers = self.providers.read().await;
if let Some(provider) = providers.get(provider_id) {
if !provider.enabled {
return Ok(None);
}
let client = reqwest::Client::new();
let response = client
.get(&provider.user_info_url)
.bearer_auth(access_token)
.send()
.await
.map_err(|e| crate::core::RiError::ExternalError(e.to_string()))?;
if response.status().is_success() {
let user_data: serde_json::Value = response.json().await
.map_err(|e| crate::core::RiError::ExternalError(e.to_string()))?;
let user_info = RiOAuthUserInfo {
id: user_data["id"]
.as_str()
.ok_or_else(|| crate::core::RiError::ExternalError("Missing user id".to_string()))?
.to_string(),
email: user_data["email"]
.as_str()
.ok_or_else(|| crate::core::RiError::ExternalError("Missing email".to_string()))?
.to_string(),
name: user_data["name"].as_str().map(String::from),
avatar_url: user_data["avatar_url"].as_str().map(String::from),
provider: provider_id.to_string(),
};
Ok(Some(user_info))
} else {
Ok(None)
}
} else {
Ok(None)
}
}
#[cfg(not(feature = "http_client"))]
pub async fn get_user_info(
&self,
_provider_id: &str,
_access_token: &str,
) -> crate::core::RiResult<Option<RiOAuthUserInfo>> {
Err(crate::core::RiError::Other("HTTP client is not enabled. Enable the 'http_client' feature to use OAuth functionality.".to_string()))
}
#[cfg(feature = "http_client")]
pub async fn refresh_token(
&self,
provider_id: &str,
refresh_token: &str,
) -> crate::core::RiResult<Option<RiOAuthToken>> {
let providers = self.providers.read().await;
if let Some(provider) = providers.get(provider_id) {
if !provider.enabled {
return Ok(None);
}
let client = reqwest::Client::new();
let params = [
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
("client_id", &provider.client_id),
("client_secret", &provider.client_secret),
];
let response = client
.post(&provider.token_url)
.form(¶ms)
.send()
.await
.map_err(|e| crate::core::RiError::ExternalError(e.to_string()))?;
if response.status().is_success() {
let token_data: serde_json::Value = response.json().await
.map_err(|e| crate::core::RiError::ExternalError(e.to_string()))?;
let token = RiOAuthToken {
access_token: token_data["access_token"]
.as_str()
.ok_or_else(|| crate::core::RiError::ExternalError("Missing access_token".to_string()))?
.to_string(),
refresh_token: token_data["refresh_token"].as_str().map(String::from),
expires_in: token_data["expires_in"].as_i64(),
token_type: token_data["token_type"]
.as_str()
.unwrap_or("Bearer")
.to_string(),
scope: token_data["scope"].as_str().map(String::from),
};
Ok(Some(token))
} else {
Ok(None)
}
} else {
Ok(None)
}
}
#[cfg(not(feature = "http_client"))]
pub async fn refresh_token(
&self,
_provider_id: &str,
_refresh_token: &str,
) -> crate::core::RiResult<Option<RiOAuthToken>> {
Err(crate::core::RiError::Other("HTTP client is not enabled. Enable the 'http_client' feature to use OAuth functionality.".to_string()))
}
#[cfg(feature = "http_client")]
pub async fn revoke_token(
&self,
provider_id: &str,
access_token: &str,
) -> crate::core::RiResult<bool> {
let providers = self.providers.read().await;
if let Some(provider) = providers.get(provider_id) {
if !provider.enabled {
return Ok(false);
}
let client = reqwest::Client::new();
let params = [
("token", access_token),
("client_id", &provider.client_id),
("client_secret", &provider.client_secret),
];
let response = client
.post(format!("{}/revoke", provider.token_url))
.form(¶ms)
.send()
.await
.map_err(|e| crate::core::RiError::ExternalError(e.to_string()))?;
Ok(response.status().is_success())
} else {
Ok(false)
}
}
#[cfg(not(feature = "http_client"))]
pub async fn revoke_token(
&self,
_provider_id: &str,
_access_token: &str,
) -> crate::core::RiResult<bool> {
Err(crate::core::RiError::Other("HTTP client is not enabled. Enable the 'http_client' feature to use OAuth functionality.".to_string()))
}
pub async fn list_providers(&self) -> crate::core::RiResult<Vec<RiOAuthProvider>> {
let providers = self.providers.read().await;
Ok(providers.values().cloned().collect())
}
pub async fn disable_provider(&self, provider_id: &str) -> crate::core::RiResult<bool> {
let mut providers = self.providers.write().await;
if let Some(provider) = providers.get_mut(provider_id) {
provider.enabled = false;
Ok(true)
} else {
Ok(false)
}
}
pub async fn enable_provider(&self, provider_id: &str) -> crate::core::RiResult<bool> {
let mut providers = self.providers.write().await;
if let Some(provider) = providers.get_mut(provider_id) {
provider.enabled = true;
Ok(true)
} else {
Ok(false)
}
}
}
#[cfg(feature = "pyo3")]
#[pyo3::prelude::pymethods]
impl RiOAuthManager {
#[new]
fn py_new() -> PyResult<Self> {
let cache = Arc::new(crate::cache::RiMemoryCache::new());
Ok(Self::new(cache))
}
#[pyo3(name = "register_provider")]
fn register_provider_impl(&self, provider: RiOAuthProvider) -> PyResult<bool> {
let rt = Runtime::new().map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
rt.block_on(async {
self.register_provider(provider).await?;
Ok(true)
})
}
#[pyo3(name = "get_provider")]
fn get_provider_impl(&self, provider_id: String) -> PyResult<Option<RiOAuthProvider>> {
let rt = Runtime::new().map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
rt.block_on(async {
self.get_provider(&provider_id).await.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))
})
}
#[pyo3(name = "get_auth_url")]
fn get_auth_url_impl(&self, provider_id: String, state: String) -> PyResult<Option<String>> {
let rt = Runtime::new().map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
rt.block_on(async {
self.get_auth_url(&provider_id, &state).await.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))
})
}
#[pyo3(name = "exchange_code_for_token")]
fn exchange_code_for_token_impl(&self, provider_id: String, code: String, redirect_uri: String) -> PyResult<Option<RiOAuthToken>> {
let rt = Runtime::new().map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
rt.block_on(async {
self.exchange_code_for_token(&provider_id, &code, &redirect_uri).await.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))
})
}
#[pyo3(name = "get_user_info")]
fn get_user_info_impl(&self, provider_id: String, access_token: String) -> PyResult<Option<RiOAuthUserInfo>> {
let rt = Runtime::new().map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
rt.block_on(async {
self.get_user_info(&provider_id, &access_token).await.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))
})
}
#[pyo3(name = "refresh_token")]
fn refresh_token_impl(&self, provider_id: String, refresh_token: String) -> PyResult<Option<RiOAuthToken>> {
let rt = Runtime::new().map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
rt.block_on(async {
self.refresh_token(&provider_id, &refresh_token).await.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))
})
}
#[pyo3(name = "revoke_token")]
fn revoke_token_impl(&self, provider_id: String, access_token: String) -> PyResult<bool> {
let rt = Runtime::new().map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
rt.block_on(async {
self.revoke_token(&provider_id, &access_token).await.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))
})
}
#[pyo3(name = "list_providers")]
fn list_providers_impl(&self) -> PyResult<Vec<RiOAuthProvider>> {
let rt = Runtime::new().map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
rt.block_on(async {
self.list_providers().await.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))
})
}
#[pyo3(name = "disable_provider")]
fn disable_provider_impl(&self, provider_id: String) -> PyResult<bool> {
let rt = Runtime::new().map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
rt.block_on(async {
self.disable_provider(&provider_id).await.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))
})
}
#[pyo3(name = "enable_provider")]
fn enable_provider_impl(&self, provider_id: String) -> PyResult<bool> {
let rt = Runtime::new().map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
rt.block_on(async {
self.enable_provider(&provider_id).await.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))
})
}
}