use better_auth_core::{AuthError, AuthResult, SchemaValue};
use serde_json::{Value, json};
use super::{
OAuthProvider, OAuthUserInfo, OAuthUserInfoRequest, OAuthUserInfoResponse, ProviderKind,
};
use crate::plugins::oauth::{
oidc::OidcVerifier,
social_profile::{ProviderProfile, fetch_http_profile},
};
#[derive(Clone, Default)]
pub struct FacebookOptions {
pub fields: Vec<String>,
pub additional_client_ids: Vec<String>,
#[cfg(test)]
pub(in crate::plugins::oauth) jwks_url: Option<String>,
#[cfg(test)]
pub(in crate::plugins::oauth) debug_token_url: Option<String>,
}
impl OAuthProvider {
pub fn facebook(client_id: &str, client_secret: &str, options: FacebookOptions) -> Self {
Self {
kind: ProviderKind::Facebook(options),
user_info_url: Some("https://graph.facebook.com/me".into()),
..Self::custom(
client_id,
client_secret,
"https://www.facebook.com/v24.0/dialog/oauth",
"https://graph.facebook.com/v24.0/oauth/access_token",
)
}
}
pub(in crate::plugins::oauth) fn facebook_options(&self) -> Option<&FacebookOptions> {
match &self.kind {
ProviderKind::Facebook(options) => Some(options),
_ => None,
}
}
}
impl FacebookOptions {
pub(in crate::plugins::oauth) async fn verify(
&self,
client_id: &str,
token: &str,
nonce: Option<&str>,
) -> AuthResult<Value> {
let jwks_url = "https://limited.facebook.com/.well-known/oauth/openid/jwks/";
#[cfg(test)]
let jwks_url = self.jwks_url.as_deref().unwrap_or(jwks_url);
let audiences = std::iter::once(client_id.to_owned())
.chain(self.additional_client_ids.iter().cloned())
.collect();
let verifier = OidcVerifier::with_policy(
jwks_url
.parse()
.map_err(|error: url::ParseError| AuthError::internal(error.to_string()))?,
"https://www.facebook.com".into(),
audiences,
Some(vec![json!("RS256")]),
None,
)
.map_err(|error| AuthError::internal(error.to_string()))?;
verifier
.verify(token, nonce)
.await
.map_err(|_| crate::plugins::oauth::id_token::invalid())
}
}
pub(in crate::plugins::oauth) async fn fetch_user_info(
provider: &OAuthProvider,
options: &FacebookOptions,
request: OAuthUserInfoRequest,
claims: Option<Value>,
) -> AuthResult<ProviderProfile> {
let (data, limited) = if let Some(claims) = claims {
(claims, true)
} else {
if !request.access_token.is_truthy()? {
return Ok(ProviderProfile::default());
}
if provider.client_id.is_empty() || provider.client_secret.is_empty() {
return Ok(ProviderProfile::default());
}
let debug_url = "https://graph.facebook.com/debug_token";
#[cfg(test)]
let debug_url = options.debug_token_url.as_deref().unwrap_or(debug_url);
let access_token = super::token_http::form_string(&request.access_token.field_value())?;
let inspection = reqwest::Client::new().get(debug_url).query(&[
("input_token", access_token.as_str()),
(
"access_token",
&format!("{}|{}", provider.client_id, provider.client_secret),
),
]);
let Some(inspection) = fetch_http_profile(inspection).await? else {
return Ok(ProviderProfile::default());
};
let Some(data) = inspection.get("data") else {
return Ok(ProviderProfile::default());
};
let Some(app_id) = data
.get("app_id")
.and_then(Value::as_str)
.filter(|id| !id.is_empty())
else {
return Ok(ProviderProfile::default());
};
let Some(user_id) = data
.get("user_id")
.and_then(Value::as_str)
.filter(|id| !id.is_empty())
else {
return Ok(ProviderProfile::default());
};
if data.get("is_valid") != Some(&Value::Bool(true))
|| (app_id != provider.client_id
&& !options.additional_client_ids.iter().any(|id| id == app_id))
{
return Ok(ProviderProfile::default());
}
let endpoint = provider
.user_info_url
.as_deref()
.ok_or_else(|| AuthError::internal("Missing user_info_url for provider"))?;
let fields = ["id", "name", "email", "picture"]
.into_iter()
.chain(options.fields.iter().map(String::as_str))
.collect::<Vec<_>>()
.join(",");
let profile = reqwest::Client::new()
.get(endpoint)
.query(&[("fields", fields)])
.header(
reqwest::header::AUTHORIZATION,
super::token_http::authorization("Bearer", &request.access_token.field_value())?,
);
let Some(profile) = fetch_http_profile(profile).await? else {
return Ok(ProviderProfile::default());
};
if profile.get("id").and_then(Value::as_str) != Some(user_id) {
return Ok(ProviderProfile::default());
}
(profile, false)
};
let user = if let Some(decode) = provider.map_user_info {
decode(data.clone())
} else {
decode_profile(&data, limited)
}
.map_err(AuthError::internal)?;
ProviderProfile::from_typed(provider, OAuthUserInfoResponse { user, data })?
.map_user(provider)
.await
}
pub(super) fn graph_profile(profile: Value) -> Result<OAuthUserInfo, String> {
decode_profile(&profile, false)
}
fn decode_profile(profile: &Value, limited: bool) -> Result<OAuthUserInfo, String> {
Ok(OAuthUserInfo {
id: profile
.get(if limited { "sub" } else { "id" })
.and_then(Value::as_str)
.ok_or("missing Facebook subject")?
.into(),
name: profile
.get("name")
.cloned()
.map(serde_json::from_value::<Option<String>>)
.transpose()
.map(|value| value.map(Into::into).unwrap_or_default())
.map_err(|error| format!("Invalid Facebook name: {error}"))?,
email: super::defaults::profile_email(profile)?,
image: (if limited {
profile.get("picture")
} else {
profile.pointer("/picture/data/url")
})
.cloned()
.map(serde_json::from_value)
.transpose()
.map_err(|error| format!("Invalid Facebook picture: {error}"))?,
email_verified: if limited {
Some(false).into()
} else {
SchemaValue::from_json(Some(
profile
.get("email_verified")
.filter(|value| !value.is_null())
.cloned()
.unwrap_or(Value::Bool(false)),
))
.map_err(|error| error.to_string())?
},
additional_fields: Default::default(),
})
}