pub mod keys;
pub mod models;
pub mod project_config;
pub mod project_config_impl;
pub mod tenant_mgt;
pub mod verifier;
use crate::auth::models::{
ActionCodeSettings, CreateSessionCookieRequest, CreateSessionCookieResponse, CreateUserRequest,
DeleteAccountRequest, EmailLinkRequest, EmailLinkResponse, GetAccountInfoRequest,
GetAccountInfoResponse, ImportUsersRequest, ImportUsersResponse, ListUsersResponse,
UpdateUserRequest, UserRecord,
};
use crate::auth::project_config_impl::ProjectConfig;
use crate::auth::tenant_mgt::TenantAwareness;
use crate::auth::verifier::{FirebaseTokenClaims, IdTokenVerifier, TokenVerificationError};
use crate::core::middleware::AuthMiddleware;
use crate::core::parse_error_response;
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use reqwest::header;
use reqwest::Client;
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
use serde::Serialize;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use thiserror::Error;
use url::Url;
const AUTH_V1_API: &str = "https://identitytoolkit.googleapis.com/v1/projects/{project_id}";
const AUTH_V1_TENANT_API: &str =
"https://identitytoolkit.googleapis.com/v1/projects/{project_id}/tenants/{tenant_id}";
#[derive(Error, Debug)]
pub enum AuthError {
#[error("HTTP Request failed: {0}")]
RequestError(#[from] reqwest::Error),
#[error("Middleware error: {0}")]
MiddlewareError(#[from] reqwest_middleware::Error),
#[error("API error: {0}")]
ApiError(String),
#[error("User not found")]
UserNotFound,
#[error("Serialization error: {0}")]
SerializationError(#[from] serde_json::Error),
#[error("Token verification error: {0}")]
TokenVerificationError(#[from] TokenVerificationError),
#[error("JWT error: {0}")]
JwtError(#[from] jsonwebtoken::errors::Error),
#[error("Invalid private key")]
InvalidPrivateKey,
#[error("Service account key required for this operation")]
ServiceAccountKeyRequired,
#[error("Import users error: {0:?}")]
ImportUsersError(Vec<models::ImportUserError>),
}
#[derive(Debug, Serialize)]
struct CustomTokenClaims {
iss: String,
sub: String,
aud: String,
iat: usize,
exp: usize,
uid: String,
#[serde(flatten)]
claims: Option<serde_json::Map<String, serde_json::Value>>,
}
#[derive(Clone)]
pub struct FirebaseAuth {
client: ClientWithMiddleware,
base_url: String,
verifier: Arc<IdTokenVerifier>,
middleware: AuthMiddleware,
tenant_id: Option<String>,
}
impl FirebaseAuth {
pub fn new(middleware: AuthMiddleware) -> Self {
let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3);
let client = ClientBuilder::new(Client::new())
.with(RetryTransientMiddleware::new_with_policy(retry_policy))
.with(middleware.clone())
.build();
let key = &middleware.key;
let project_id = key.project_id.clone().unwrap_or_default();
let verifier = Arc::new(IdTokenVerifier::new(project_id.clone()));
let tenant_id = middleware.tenant_id();
let base_url = if let Some(tid) = &tenant_id {
AUTH_V1_TENANT_API
.replace("{project_id}", &project_id)
.replace("{tenant_id}", tid)
} else {
AUTH_V1_API.replace("{project_id}", &project_id)
};
Self {
client,
base_url,
verifier,
middleware,
tenant_id,
}
}
#[cfg(test)]
pub(crate) fn new_with_client(client: ClientWithMiddleware, base_url: String) -> Self {
let key = yup_oauth2::ServiceAccountKey {
key_type: Some("service_account".to_string()),
client_email: "test@example.com".to_string(),
private_key: "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC6\n-----END PRIVATE KEY-----".to_string(),
project_id: Some("test-project".to_string()),
private_key_id: None,
client_id: None,
auth_uri: None,
token_uri: "https://oauth2.googleapis.com/token".to_string(),
auth_provider_x509_cert_url: None,
client_x509_cert_url: None,
};
let middleware = AuthMiddleware::new(key);
let verifier = Arc::new(IdTokenVerifier::new("test-project".to_string()));
Self {
client,
base_url,
verifier,
middleware,
tenant_id: None,
}
}
pub fn tenant_manager(&self) -> TenantAwareness {
TenantAwareness::new(self.middleware.clone())
}
pub fn project_config_manager(&self) -> ProjectConfig {
ProjectConfig::new(self.middleware.clone())
}
pub async fn verify_id_token(&self, token: &str) -> Result<FirebaseTokenClaims, AuthError> {
Ok(self.verifier.verify_id_token(token).await?)
}
pub async fn create_session_cookie(
&self,
id_token: &str,
valid_duration: std::time::Duration,
) -> Result<String, AuthError> {
let url = format!("{}:createSessionCookie", self.base_url);
let request = CreateSessionCookieRequest {
id_token: id_token.to_string(),
valid_duration_seconds: valid_duration.as_secs(),
};
let response = self
.client
.post(&url)
.header(header::CONTENT_TYPE, "application/json")
.body(serde_json::to_vec(&request)?)
.send()
.await?;
if !response.status().is_success() {
return Err(AuthError::ApiError(
parse_error_response(response, "Create session cookie failed").await,
));
}
let result: CreateSessionCookieResponse = response.json().await?;
Ok(result.session_cookie)
}
pub async fn verify_session_cookie(
&self,
session_cookie: &str,
) -> Result<FirebaseTokenClaims, AuthError> {
Ok(self.verifier.verify_session_cookie(session_cookie).await?)
}
pub fn create_custom_token(
&self,
uid: &str,
custom_claims: Option<serde_json::Map<String, serde_json::Value>>,
) -> Result<String, AuthError> {
let key = &self.middleware.key;
let client_email = key.client_email.clone();
let private_key = key.private_key.clone();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as usize;
let mut final_claims = custom_claims.unwrap_or_default();
if let Some(tid) = &self.tenant_id {
final_claims.insert(
"tenant_id".to_string(),
serde_json::Value::String(tid.clone()),
);
}
let claims = CustomTokenClaims {
iss: client_email.clone(),
sub: client_email,
aud: "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit".to_string(),
iat: now,
exp: now + 3600, uid: uid.to_string(),
claims: Some(final_claims),
};
let encoding_key = EncodingKey::from_rsa_pem(private_key.as_bytes())
.map_err(|_| AuthError::InvalidPrivateKey)?;
let header = Header::new(Algorithm::RS256);
let token = encode(&header, &claims, &encoding_key)?;
Ok(token)
}
async fn generate_email_link(
&self,
request_type: &str,
email: &str,
settings: Option<ActionCodeSettings>,
) -> Result<String, AuthError> {
let url = format!("{}/accounts:sendOobCode", self.base_url,);
let mut request = EmailLinkRequest {
request_type: request_type.to_string(),
email: Some(email.to_string()),
..Default::default()
};
if let Some(s) = settings {
request.continue_url = Some(s.url);
request.can_handle_code_in_app = s.handle_code_in_app;
request.dynamic_link_domain = s.dynamic_link_domain;
if let Some(ios) = s.ios {
request.ios_bundle_id = Some(ios.bundle_id);
}
if let Some(android) = s.android {
request.android_package_name = Some(android.package_name);
request.android_install_app = android.install_app;
request.android_minimum_version = android.minimum_version;
}
}
let response = self
.client
.post(&url)
.header(header::CONTENT_TYPE, "application/json")
.body(serde_json::to_vec(&request)?)
.send()
.await?;
if !response.status().is_success() {
return Err(AuthError::ApiError(
parse_error_response(response, "Generate email link failed").await,
));
}
let result: EmailLinkResponse = response.json().await?;
Ok(result.oob_link)
}
pub async fn generate_password_reset_link(
&self,
email: &str,
settings: Option<ActionCodeSettings>,
) -> Result<String, AuthError> {
self.generate_email_link("PASSWORD_RESET", email, settings)
.await
}
pub async fn generate_email_verification_link(
&self,
email: &str,
settings: Option<ActionCodeSettings>,
) -> Result<String, AuthError> {
self.generate_email_link("VERIFY_EMAIL", email, settings)
.await
}
pub async fn generate_sign_in_with_email_link(
&self,
email: &str,
settings: Option<ActionCodeSettings>,
) -> Result<String, AuthError> {
self.generate_email_link("EMAIL_SIGNIN", email, settings)
.await
}
pub async fn import_users(
&self,
request: ImportUsersRequest,
) -> Result<ImportUsersResponse, AuthError> {
let url = format!("{}/accounts:batchCreate", self.base_url,);
let response = self
.client
.post(&url)
.header(header::CONTENT_TYPE, "application/json")
.body(serde_json::to_vec(&request)?)
.send()
.await?;
if !response.status().is_success() {
return Err(AuthError::ApiError(
parse_error_response(response, "Import users failed").await,
));
}
let result: ImportUsersResponse = response.json().await?;
if let Some(errors) = &result.error {
if !errors.is_empty() {
return Err(AuthError::ImportUsersError(
errors
.iter()
.map(|e| models::ImportUserError {
index: e.index,
message: e.message.clone(),
})
.collect(),
));
}
}
Ok(result)
}
pub async fn create_user(&self, request: CreateUserRequest) -> Result<UserRecord, AuthError> {
let url = format!("{}/accounts", self.base_url);
let response = self
.client
.post(&url)
.header(header::CONTENT_TYPE, "application/json")
.body(serde_json::to_vec(&request)?)
.send()
.await?;
if !response.status().is_success() {
return Err(AuthError::ApiError(
parse_error_response(response, "Create user failed").await,
));
}
let user: UserRecord = response.json().await?;
Ok(user)
}
pub async fn update_user(&self, request: UpdateUserRequest) -> Result<UserRecord, AuthError> {
let url = format!("{}/accounts:update", self.base_url);
let response = self
.client
.post(&url)
.header(header::CONTENT_TYPE, "application/json")
.body(serde_json::to_vec(&request)?)
.send()
.await?;
if !response.status().is_success() {
return Err(AuthError::ApiError(
parse_error_response(response, "Update user failed").await,
));
}
let user: UserRecord = response.json().await?;
Ok(user)
}
pub async fn delete_user(&self, uid: &str) -> Result<(), AuthError> {
let url = format!("{}/accounts:delete", self.base_url);
let request = DeleteAccountRequest {
local_id: uid.to_string(),
};
let response = self
.client
.post(&url)
.header(header::CONTENT_TYPE, "application/json")
.body(serde_json::to_vec(&request)?)
.send()
.await?;
if !response.status().is_success() {
return Err(AuthError::ApiError(
parse_error_response(response, "Delete user failed").await,
));
}
Ok(())
}
async fn get_account_info(
&self,
request: GetAccountInfoRequest,
) -> Result<UserRecord, AuthError> {
let url = format!("{}/accounts:lookup", self.base_url);
let response = self
.client
.post(&url)
.header(header::CONTENT_TYPE, "application/json")
.body(serde_json::to_vec(&request)?)
.send()
.await?;
if !response.status().is_success() {
return Err(AuthError::ApiError(
parse_error_response(response, "Get user failed").await,
));
}
let result: GetAccountInfoResponse = response.json().await?;
result
.users
.and_then(|mut users| users.pop())
.ok_or(AuthError::UserNotFound)
}
pub async fn get_user(&self, uid: &str) -> Result<UserRecord, AuthError> {
let request = GetAccountInfoRequest {
local_id: Some(vec![uid.to_string()]),
email: None,
phone_number: None,
};
self.get_account_info(request).await
}
pub async fn get_user_by_email(&self, email: &str) -> Result<UserRecord, AuthError> {
let request = GetAccountInfoRequest {
local_id: None,
email: Some(vec![email.to_string()]),
phone_number: None,
};
self.get_account_info(request).await
}
pub async fn get_user_by_phone_number(&self, phone: &str) -> Result<UserRecord, AuthError> {
let request = GetAccountInfoRequest {
local_id: None,
email: None,
phone_number: Some(vec![phone.to_string()]),
};
self.get_account_info(request).await
}
pub async fn list_users(
&self,
max_results: u32,
page_token: Option<&str>,
) -> Result<ListUsersResponse, AuthError> {
let url = format!("{}/accounts", self.base_url);
let mut url_obj = Url::parse(&url).map_err(|e| AuthError::ApiError(e.to_string()))?;
{
let mut query_pairs = url_obj.query_pairs_mut();
query_pairs.append_pair("maxResults", &max_results.to_string());
if let Some(token) = page_token {
query_pairs.append_pair("nextPageToken", token);
}
}
let response = self.client.get(url_obj).send().await?;
if !response.status().is_success() {
return Err(AuthError::ApiError(
parse_error_response(response, "List users failed").await,
));
}
let result: ListUsersResponse = response.json().await?;
Ok(result)
}
}
#[cfg(test)]
mod tests;