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:
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user