mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
Add shared OAuth flows
This commit is contained in:
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user