|
|
|
|
@ -1,22 +1,153 @@
|
|
|
|
|
use crate::auth::domain::AuthClaims; |
|
|
|
|
use crate::auth::traits::Authenticator; |
|
|
|
|
use crate::common::errors::AuthError; |
|
|
|
|
use jsonwebtoken::{ |
|
|
|
|
Algorithm, DecodingKey, Validation, dangerous_insecure_decode, decode, decode_header, |
|
|
|
|
}; |
|
|
|
|
use serde::{Deserialize, Serialize}; |
|
|
|
|
use std::collections::HashMap; |
|
|
|
|
use std::sync::Arc; |
|
|
|
|
use std::time::Duration; |
|
|
|
|
use tokio::sync::RwLock; |
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)] |
|
|
|
|
struct FirebaseClaims { |
|
|
|
|
iss: String, |
|
|
|
|
aud: String, |
|
|
|
|
auth_time: i64, |
|
|
|
|
user_id: String, |
|
|
|
|
sub: String, |
|
|
|
|
iat: i64, |
|
|
|
|
exp: i64, |
|
|
|
|
email: Option<String>, |
|
|
|
|
email_verified: Option<bool>, |
|
|
|
|
firebase: Option<FirebaseData>, |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)] |
|
|
|
|
struct FirebaseData { |
|
|
|
|
identities: Option<serde_json::Value>, |
|
|
|
|
sign_in_provider: Option<String>, |
|
|
|
|
tenant: Option<String>, |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Deserialize)] |
|
|
|
|
struct PublicKey { |
|
|
|
|
n: String, |
|
|
|
|
e: String, |
|
|
|
|
kid: String, |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Deserialize)] |
|
|
|
|
struct JwksResponse { |
|
|
|
|
keys: Vec<PublicKey>, |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
#[derive(Clone)] |
|
|
|
|
struct KeyCache { |
|
|
|
|
keys: HashMap<String, DecodingKey>, |
|
|
|
|
expires_at: i64, |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
pub struct JwtAuthenticator { |
|
|
|
|
firebase_project_id: String, |
|
|
|
|
issuer: String, |
|
|
|
|
audience: String, |
|
|
|
|
emulator_url: Option<String>, |
|
|
|
|
key_cache: Arc<RwLock<Option<KeyCache>>>, |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
impl JwtAuthenticator { |
|
|
|
|
pub fn new(firebase_project_id: impl Into<String>) -> Self { |
|
|
|
|
let project_id = firebase_project_id.into(); |
|
|
|
|
let issuer = format!("https://securetoken.google.com/{}", project_id); |
|
|
|
|
Self { |
|
|
|
|
firebase_project_id: firebase_project_id.into(), |
|
|
|
|
firebase_project_id: project_id.clone(), |
|
|
|
|
issuer, |
|
|
|
|
audience: project_id, |
|
|
|
|
emulator_url: None, |
|
|
|
|
key_cache: Arc::new(RwLock::new(None)), |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
pub fn with_emulator(emulator_url: impl Into<String>) -> Self { |
|
|
|
|
Self { |
|
|
|
|
firebase_project_id: "emulator".to_string(), |
|
|
|
|
issuer: "https://securetoken.google.com/emulator".to_string(), |
|
|
|
|
audience: "emulator".to_string(), |
|
|
|
|
emulator_url: Some(emulator_url.into()), |
|
|
|
|
key_cache: Arc::new(RwLock::new(None)), |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
pub fn with_placeholder() -> Self { |
|
|
|
|
Self { |
|
|
|
|
firebase_project_id: "FIREBASE_PROJECT_ID_PLACEHOLDER".to_string(), |
|
|
|
|
issuer: "https://securetoken.google.com/FIREBASE_PROJECT_ID_PLACEHOLDER".to_string(), |
|
|
|
|
audience: "FIREBASE_PROJECT_ID_PLACEHOLDER".to_string(), |
|
|
|
|
emulator_url: None, |
|
|
|
|
key_cache: Arc::new(RwLock::new(None)), |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
async fn get_public_keys(&self) -> Result<HashMap<String, DecodingKey>, AuthError> { |
|
|
|
|
let cache = self.key_cache.read().await; |
|
|
|
|
if let Some(ref cached) = *cache { |
|
|
|
|
let now = chrono::Utc::now().timestamp(); |
|
|
|
|
if cached.expires_at > now { |
|
|
|
|
return Ok(cached.keys.clone()); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
drop(cache); |
|
|
|
|
|
|
|
|
|
let url = if let Some(ref emulator) = self.emulator_url { |
|
|
|
|
format!("{}/.well-known/jwks.json", emulator.trim_end_matches('/')) |
|
|
|
|
} else { |
|
|
|
|
format!( |
|
|
|
|
"https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com" |
|
|
|
|
) |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
let client = reqwest::Client::builder() |
|
|
|
|
.timeout(Duration::from_secs(10)) |
|
|
|
|
.build() |
|
|
|
|
.map_err(|e| { |
|
|
|
|
AuthError::AuthenticationFailed(format!("Failed to build HTTP client: {}", e)) |
|
|
|
|
})?; |
|
|
|
|
|
|
|
|
|
let response = client.get(&url).send().await.map_err(|e| { |
|
|
|
|
AuthError::AuthenticationFailed(format!("Failed to fetch public keys: {}", e)) |
|
|
|
|
})?; |
|
|
|
|
|
|
|
|
|
if !response.status().is_success() { |
|
|
|
|
return Err(AuthError::AuthenticationFailed(format!( |
|
|
|
|
"Failed to fetch public keys: HTTP {}", |
|
|
|
|
response.status() |
|
|
|
|
))); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
let body = response.text().await.map_err(|e| { |
|
|
|
|
AuthError::AuthenticationFailed(format!("Failed to read response: {}", e)) |
|
|
|
|
})?; |
|
|
|
|
|
|
|
|
|
let jwks: JwksResponse = serde_json::from_str(&body) |
|
|
|
|
.map_err(|e| AuthError::AuthenticationFailed(format!("Failed to parse JWKS: {}", e)))?; |
|
|
|
|
|
|
|
|
|
let mut keys = HashMap::new(); |
|
|
|
|
for key in jwks.keys { |
|
|
|
|
let decoding_key = DecodingKey::from_rsa_components(&key.n, &key.e).map_err(|e| { |
|
|
|
|
AuthError::AuthenticationFailed(format!("Failed to create decoding key: {}", e)) |
|
|
|
|
})?; |
|
|
|
|
keys.insert(key.kid, decoding_key); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
let expires_at = chrono::Utc::now().timestamp() + 3600; |
|
|
|
|
let new_cache = KeyCache { keys, expires_at }; |
|
|
|
|
|
|
|
|
|
let mut cache = self.key_cache.write().await; |
|
|
|
|
*cache = Some(new_cache); |
|
|
|
|
|
|
|
|
|
Ok(cache.as_ref().unwrap().keys.clone()) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
@ -29,40 +160,53 @@ impl Authenticator for JwtAuthenticator {
|
|
|
|
|
)); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
let parts: Vec<&str> = token.split('.').collect(); |
|
|
|
|
if parts.len() != 3 { |
|
|
|
|
return Err(AuthError::InvalidToken); |
|
|
|
|
} |
|
|
|
|
let header = decode_header(token).map_err(|e| { |
|
|
|
|
tracing::debug!("Failed to decode header: {}", e.to_string()); |
|
|
|
|
AuthError::InvalidToken |
|
|
|
|
})?; |
|
|
|
|
|
|
|
|
|
let claims = AuthClaims::new("firebase_user_123") |
|
|
|
|
.with_email("user@example.com") |
|
|
|
|
.with_role("user"); |
|
|
|
|
let kid = header |
|
|
|
|
.kid |
|
|
|
|
.ok_or_else(|| AuthError::AuthenticationFailed("Token missing key ID".to_string()))?; |
|
|
|
|
|
|
|
|
|
Ok(claims) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
let public_keys = self.get_public_keys().await?; |
|
|
|
|
let decoding_key = public_keys |
|
|
|
|
.get(&kid) |
|
|
|
|
.ok_or_else(|| AuthError::AuthenticationFailed("Unknown key ID".to_string()))?; |
|
|
|
|
|
|
|
|
|
let mut validation = Validation::new(Algorithm::RS256); |
|
|
|
|
validation.set_audience(&[&self.audience]); |
|
|
|
|
validation.set_issuer(&[&self.issuer]); |
|
|
|
|
validation.validate_exp = true; |
|
|
|
|
|
|
|
|
|
pub struct JwtConfig { |
|
|
|
|
pub firebase_project_id: String, |
|
|
|
|
pub issuer: String, |
|
|
|
|
pub audience: String, |
|
|
|
|
let token_data = |
|
|
|
|
decode::<FirebaseClaims>(token, decoding_key, &validation).map_err(|e| { |
|
|
|
|
AuthError::AuthenticationFailed(format!("Token validation failed: {}", e)) |
|
|
|
|
})?; |
|
|
|
|
|
|
|
|
|
let mut claims = AuthClaims::new(token_data.claims.user_id) |
|
|
|
|
.with_expiration(token_data.claims.exp) |
|
|
|
|
.with_iat(token_data.claims.iat); |
|
|
|
|
|
|
|
|
|
if let Some(email) = token_data.claims.email { |
|
|
|
|
claims = claims.with_email(email); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
impl JwtConfig { |
|
|
|
|
pub fn new(firebase_project_id: String) -> Self { |
|
|
|
|
let issuer = format!("https://securetoken.google.com/{}", firebase_project_id); |
|
|
|
|
Self { |
|
|
|
|
firebase_project_id: firebase_project_id.clone(), |
|
|
|
|
issuer, |
|
|
|
|
audience: firebase_project_id, |
|
|
|
|
if let Some(ref firebase) = token_data.claims.firebase { |
|
|
|
|
if let Some(ref provider) = firebase.sign_in_provider { |
|
|
|
|
claims = claims.with_role(format!("auth:{}", provider)); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
pub fn placeholder() -> Self { |
|
|
|
|
Self { |
|
|
|
|
firebase_project_id: "FIREBASE_PROJECT_ID_PLACEHOLDER".to_string(), |
|
|
|
|
issuer: "https://securetoken.google.com/FIREBASE_PROJECT_ID_PLACEHOLDER".to_string(), |
|
|
|
|
audience: "FIREBASE_PROJECT_ID_PLACEHOLDER".to_string(), |
|
|
|
|
claims = claims.with_role("authenticated"); |
|
|
|
|
|
|
|
|
|
Ok(claims) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
impl AuthClaims { |
|
|
|
|
pub fn with_iat(mut self, iat: i64) -> Self { |
|
|
|
|
self.iat = iat; |
|
|
|
|
self |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|