use std::{fmt::Write as _, time::Duration};
use async_trait::async_trait;
use serde::Deserialize;
use zeroize::Zeroizing;
use crate::{
error::{AuthError, Result},
oidc_provider::validate_oauth_endpoint_url,
provider::{OAuthProvider, TokenResponse, UserInfo},
};
const FACEBOOK_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_FACEBOOK_RESPONSE_BYTES: usize = 1024 * 1024;
const DEFAULT_BASE_URL: &str = "https://www.facebook.com";
const DEFAULT_GRAPH_BASE_URL: &str = "https://graph.facebook.com";
pub const DEFAULT_API_VERSION: &str = "v21.0";
const FACEBOOK_SCOPES: &str = "email";
const FACEBOOK_ME_FIELDS: &str = "id,name,email";
#[derive(Debug, Clone, Deserialize)]
pub struct FacebookUser {
pub id: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub email: Option<String>,
}
#[derive(Debug, Deserialize)]
struct FacebookTokenResponse {
access_token: String,
#[serde(default)]
token_type: Option<String>,
#[serde(default)]
expires_in: Option<u64>,
}
pub struct FacebookOAuth {
client_id: String,
client_secret: Zeroizing<String>,
redirect_uri: String,
base_url: String,
graph_base_url: String,
api_version: String,
client: reqwest::Client,
}
impl std::fmt::Debug for FacebookOAuth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FacebookOAuth")
.field("client_id", &self.client_id)
.field("redirect_uri", &self.redirect_uri)
.field("base_url", &self.base_url)
.field("graph_base_url", &self.graph_base_url)
.field("api_version", &self.api_version)
.finish_non_exhaustive() }
}
impl FacebookOAuth {
pub fn new(client_id: String, client_secret: String, redirect_uri: String) -> Result<Self> {
Self::with_endpoints(
client_id,
client_secret,
redirect_uri,
DEFAULT_BASE_URL.to_string(),
DEFAULT_GRAPH_BASE_URL.to_string(),
DEFAULT_API_VERSION.to_string(),
)
}
pub fn with_endpoints(
client_id: String,
client_secret: String,
redirect_uri: String,
base_url: String,
graph_base_url: String,
api_version: String,
) -> Result<Self> {
validate_oauth_endpoint_url(&base_url)?;
validate_oauth_endpoint_url(&graph_base_url)?;
let version = api_version.trim();
if version.is_empty() || version.contains(['/', '?', '#', '\\']) {
return Err(AuthError::ConfigError {
message: format!(
"[auth.social.facebook] api_version {api_version:?} is not a Graph API \
version segment (expected something like {DEFAULT_API_VERSION})"
),
});
}
let client =
reqwest::Client::builder()
.timeout(FACEBOOK_REQUEST_TIMEOUT)
.build()
.map_err(|e| AuthError::ConfigError {
message: format!("Failed to create HTTP client: {e}"),
})?;
Ok(Self {
client_id,
client_secret: Zeroizing::new(client_secret),
redirect_uri,
base_url: base_url.trim_end_matches('/').to_string(),
graph_base_url: graph_base_url.trim_end_matches('/').to_string(),
api_version: version.to_string(),
client,
})
}
pub async fn get_user(&self, access_token: &str) -> Result<FacebookUser> {
let resp = self
.client
.get(format!(
"{}/{}/me?fields={FACEBOOK_ME_FIELDS}",
self.graph_base_url, self.api_version
))
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| AuthError::OAuthError {
message: format!("Failed to fetch Facebook profile: {e}"),
})?;
let status = resp.status();
let bytes = resp.bytes().await.map_err(|e| AuthError::OAuthError {
message: format!("Failed to read Facebook profile response: {e}"),
})?;
if !status.is_success() {
return Err(AuthError::OAuthError {
message: format!("Facebook /me returned HTTP {status}"),
});
}
if bytes.len() > MAX_FACEBOOK_RESPONSE_BYTES {
return Err(AuthError::OAuthError {
message: format!("Facebook profile response too large ({} bytes)", bytes.len()),
});
}
serde_json::from_slice(&bytes).map_err(|e| AuthError::OAuthError {
message: format!("Failed to parse Facebook profile: {e}"),
})
}
}
#[async_trait]
impl OAuthProvider for FacebookOAuth {
fn name(&self) -> &'static str {
"facebook"
}
fn authorization_url(&self, state: &str) -> String {
let mut url = format!("{}/{}/dialog/oauth", self.base_url, self.api_version);
write!(
url,
"?client_id={}&redirect_uri={}&state={}&response_type=code&scope={}",
urlencoding::encode(&self.client_id),
urlencoding::encode(&self.redirect_uri),
urlencoding::encode(state),
urlencoding::encode(FACEBOOK_SCOPES),
)
.expect("write to String is infallible");
url
}
async fn exchange_code(&self, code: &str) -> Result<TokenResponse> {
let resp = self
.client
.post(format!("{}/{}/oauth/access_token", self.graph_base_url, self.api_version))
.form(&[
("client_id", self.client_id.as_str()),
("client_secret", self.client_secret.as_str()),
("code", code),
("redirect_uri", self.redirect_uri.as_str()),
])
.send()
.await
.map_err(|e| AuthError::OAuthError {
message: format!("Facebook token endpoint request failed: {e}"),
})?;
let status = resp.status();
let bytes = resp.bytes().await.map_err(|e| AuthError::OAuthError {
message: format!("Failed to read Facebook token response: {e}"),
})?;
if !status.is_success() {
return Err(AuthError::OAuthError {
message: format!("Facebook token endpoint returned HTTP {status}"),
});
}
if bytes.len() > MAX_FACEBOOK_RESPONSE_BYTES {
return Err(AuthError::OAuthError {
message: format!("Facebook token response too large ({} bytes)", bytes.len()),
});
}
let response: FacebookTokenResponse =
serde_json::from_slice(&bytes).map_err(|e| AuthError::OAuthError {
message: format!("Failed to parse Facebook token response: {e}"),
})?;
Ok(TokenResponse {
access_token: response.access_token,
refresh_token: None,
expires_in: response.expires_in.unwrap_or(0),
token_type: response.token_type.unwrap_or_else(|| "Bearer".to_string()),
id_token: None,
})
}
async fn user_info(&self, access_token: &str) -> Result<UserInfo> {
let user = self.get_user(access_token).await?;
let email = user.email.clone().filter(|e| !e.trim().is_empty());
let mut raw_claims = serde_json::Map::new();
raw_claims.insert("facebook_id".to_string(), serde_json::json!(user.id));
if let Some(ref email) = email {
raw_claims.insert("email".to_string(), serde_json::json!(email));
}
raw_claims.insert("email_verified".to_string(), serde_json::json!(false));
Ok(UserInfo {
id: user.id.clone(),
email,
email_verified: false,
name: user.name,
picture: None,
raw_claims: serde_json::Value::Object(raw_claims),
})
}
}