use async_trait::async_trait;
use better_auth_core::{AuthError, AuthResult, FieldDate, FieldValue, NativeRequest, SchemaValue};
use indexmap::IndexMap;
use serde::Deserialize;
use serde_json::Value;
pub(super) mod apple;
mod constructors;
pub(super) mod defaults;
pub(super) mod facebook;
pub(super) mod line;
pub(super) mod microsoft;
pub(super) mod paybin;
pub(super) mod paypal;
pub(super) mod tiktok;
pub(super) mod token_http;
pub(super) mod twitch;
pub(super) mod twitter;
pub(super) mod wechat;
use super::token::{TokenEndpointAuth, TokenEndpointSecretAuthentication, TokenGrantType};
use super::{OAuthAccountSubject, OAuthProfileMapper};
use defaults::ProviderKind;
pub(super) use defaults::github_profile;
use std::{collections::HashSet, sync::Arc};
#[derive(Clone, Default)]
pub struct OAuthConfig {
pub providers: IndexMap<String, OAuthProvider>,
}
#[derive(Debug, Clone, Default)]
pub struct OAuthTokenSet {
pub field_order: Vec<String>,
pub additional_fields: better_auth_core::FieldMap,
pub token_type: SchemaValue<Option<String>>,
pub access_token: SchemaValue<Option<String>>,
pub refresh_token: SchemaValue<Option<String>>,
pub access_token_expires_at: SchemaValue<Option<FieldDate>>,
pub refresh_token_expires_at: SchemaValue<Option<FieldDate>>,
pub scopes: SchemaValue<Vec<String>>,
pub id_token: SchemaValue<Option<String>>,
pub raw: SchemaValue<Option<Value>>,
}
#[derive(Debug, Clone)]
pub struct OAuthUserInfo {
pub additional_fields: better_auth_core::FieldMap,
pub id: String,
pub email: SchemaValue<Option<String>>,
pub name: SchemaValue<Option<String>>,
pub image: Option<Option<String>>,
pub email_verified: SchemaValue<Option<bool>>,
}
pub(super) fn decode_profile_name(value: Option<&Value>) -> SchemaValue<Option<String>> {
match value {
Some(Value::String(value)) => Some(value.clone()).into(),
Some(Value::Null) => None.into(),
None | Some(_) => SchemaValue::Undefined,
}
}
pub(super) fn profile_email_verified(value: &SchemaValue<Option<bool>>) -> AuthResult<bool> {
match value {
SchemaValue::Typed(Some(value)) | SchemaValue::Dynamic(FieldValue::Bool(value)) => {
Ok(*value)
}
SchemaValue::Undefined
| SchemaValue::Typed(None)
| SchemaValue::Dynamic(FieldValue::Null) => Ok(false),
SchemaValue::Dynamic(_) => Err(AuthError::internal(
"OAuth profile email verification must be a boolean, null, or undefined",
)),
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OAuthCallbackUserPayload {
pub name: Option<OAuthCallbackUserName>,
pub email: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OAuthCallbackUserName {
pub first_name: Option<String>,
pub last_name: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct OAuthUserInfoRequest {
pub field_order: Vec<String>,
pub additional_fields: better_auth_core::FieldMap,
pub token_type: SchemaValue<Option<String>>,
pub access_token: SchemaValue<Option<String>>,
pub refresh_token: SchemaValue<Option<String>>,
pub access_token_expires_at: SchemaValue<Option<FieldDate>>,
pub refresh_token_expires_at: SchemaValue<Option<FieldDate>>,
pub scopes: SchemaValue<Vec<String>>,
pub id_token: SchemaValue<Option<String>>,
pub raw: SchemaValue<Option<Value>>,
pub expected_id_token_nonce: SchemaValue<Option<String>>,
pub user: SchemaValue<Option<OAuthCallbackUserPayload>>,
}
#[path = "token_record.rs"]
mod token_record;
pub(super) use token_record::{joined_scopes, merge_scopes, split_scopes};
#[path = "profile_record.rs"]
mod profile_record;
pub(super) fn jwt_string(value: &FieldValue) -> AuthResult<std::borrow::Cow<'_, str>> {
match value {
FieldValue::String(value) => Ok(std::borrow::Cow::Borrowed(value)),
FieldValue::Utf16String(value) => Ok(std::borrow::Cow::Owned(String::from_utf16_lossy(
value.as_utf16(),
))),
_ => Err(AuthError::type_error("JWTs must be a string")),
}
}
#[derive(Debug, Clone)]
pub struct OAuthUserInfoResponse {
pub user: OAuthUserInfo,
pub data: Value,
}
#[async_trait]
pub trait OAuthUserInfoHandler: Send + Sync {
async fn get_user_info(
&self,
_request: OAuthUserInfoRequest,
) -> AuthResult<Option<OAuthUserInfoResponse>> {
Err(AuthError::internal(
"This OAuth user info handler does not implement the typed JSON callback",
))
}
async fn get_user_info_native(&self, request: OAuthUserInfoRequest) -> AuthResult<FieldValue> {
Ok(self
.get_user_info(request)
.await?
.map_or(FieldValue::Undefined, OAuthUserInfoResponse::into_native))
}
}
pub(super) fn json_profile_bridge(profile: &FieldValue) -> AuthResult<Value> {
profile.json()?.ok_or_else(|| {
AuthError::type_error(
"The typed JSON profile callback cannot receive an omitted JSON value",
)
})
}
#[async_trait]
pub trait OAuthRefreshTokenHandler: Send + Sync {
async fn refresh_access_token(
&self,
refresh_token: &FieldValue,
context: Option<NativeRequest<'_>>,
) -> Result<OAuthTokenSet, String>;
}
#[async_trait]
pub trait OAuthIdTokenVerifier: Send + Sync {
async fn verify_id_token(
&self,
token: &str,
nonce: Option<&str>,
context: Option<NativeRequest<'_>>,
) -> Result<bool, String>;
}
#[derive(Clone)]
pub struct OAuthProvider {
kind: ProviderKind,
pub client_id: String,
pub client_secret: String,
pub client_key: Option<String>,
pub auth_url: String,
pub token_url: String,
pub token_endpoint_auth: Option<TokenEndpointAuth>,
pub user_info_url: Option<String>,
pub redirect_uri: Option<String>,
pub end_session_endpoint: Option<String>,
pub post_logout_redirect_uri: Option<String>,
pub scopes: Option<Vec<String>>,
pub disable_default_scope: bool,
pub disable_id_token_sign_in: bool,
pub prompt: Option<String>,
pub authorization_params: Vec<(String, String)>,
pub map_user_info: Option<fn(Value) -> Result<OAuthUserInfo, String>>,
pub map_profile_to_user: Option<Arc<dyn OAuthProfileMapper>>,
pub get_user_info: Option<Arc<dyn OAuthUserInfoHandler>>,
pub account_subject: Option<Arc<dyn OAuthAccountSubject>>,
pub refresh_access_token: Option<Arc<dyn OAuthRefreshTokenHandler>>,
pub verify_id_token: Option<Arc<dyn OAuthIdTokenVerifier>>,
pub disable_implicit_sign_up: Option<bool>,
pub disable_sign_up: Option<bool>,
pub require_email_verification: Option<bool>,
pub override_user_info_on_sign_in: bool,
}
impl OAuthProvider {
pub(super) fn microsoft_options(&self) -> Option<&super::MicrosoftOptions> {
match &self.kind {
ProviderKind::Microsoft { options, .. } => Some(options),
_ => None,
}
}
pub(super) fn cognito_options(&self) -> Option<&super::CognitoOptions> {
match &self.kind {
ProviderKind::Cognito(options) => Some(options),
_ => None,
}
}
pub(super) fn supports_refresh(&self) -> bool {
!matches!(self.kind, ProviderKind::Vercel)
}
pub(super) fn user_info_request(
&self,
url: &str,
access_token: &FieldValue,
) -> AuthResult<reqwest::RequestBuilder> {
if self.is_vk() {
let access_token = token_http::form_string(access_token)?;
return Ok(reqwest::Client::new()
.post(url)
.header("Accept", "*/*")
.form(&[
("access_token", &access_token),
("client_id", &self.client_id),
]));
}
let method = match self.kind {
ProviderKind::Dropbox | ProviderKind::Linear => reqwest::Method::POST,
_ => reqwest::Method::GET,
};
let request = reqwest::Client::new().request(method, url).header(
reqwest::header::AUTHORIZATION,
token_http::authorization("Bearer", access_token)?,
);
let request = if self.is_tiktok() {
request
} else {
request.header(
"Accept",
if self.is_twitter() || self.cognito_options().is_some() {
"*/*"
} else {
"application/json"
},
)
};
Ok(if matches!(self.kind, ProviderKind::Linear) {
request.json(&serde_json::json!({
"query": "query { viewer { id name email avatarUrl active createdAt updatedAt } }"
}))
} else if self.is_reddit() {
request.header("User-Agent", "better-auth")
} else if matches!(self.kind, ProviderKind::Notion) {
request.header("Notion-Version", "2022-06-28")
} else {
request
})
}
pub(super) fn is_figma(&self) -> bool {
matches!(self.kind, ProviderKind::Figma)
}
pub(super) fn is_atlassian(&self) -> bool {
matches!(self.kind, ProviderKind::Atlassian)
}
pub(super) fn is_salesforce(&self) -> bool {
matches!(self.kind, ProviderKind::Salesforce)
}
pub(super) fn required_credentials_message(&self) -> Option<&'static str> {
if self.is_paypal() {
return Some(
"Client Id and Client Secret is required for PayPal. Make sure to provide them in the options.",
);
}
match self.kind {
ProviderKind::Apple(_) => Some(
"Client ID and client secret are required for Apple. Make sure to provide them in the options.",
),
ProviderKind::Facebook(_) => Some(
"Client ID and client secret are required for Facebook. Make sure to provide them in the options.",
),
ProviderKind::Figma => Some(
"Client Id and Client Secret are required for Figma. Make sure to provide them in the options.",
),
ProviderKind::Atlassian => Some("Client Id and Secret are required for Atlassian"),
ProviderKind::Salesforce => Some(
"Client Id and Client Secret are required for Salesforce. Make sure to provide them in the options.",
),
ProviderKind::Paybin { .. } => Some(
"Client Id and Client Secret is required for Paybin. Make sure to provide them in the options.",
),
_ => None,
}
}
pub(super) fn fixed_authorization_params(&self) -> &'static [(&'static str, &'static str)] {
match self.kind {
ProviderKind::Atlassian => &[("audience", "api.atlassian.com")],
ProviderKind::Notion => &[("owner", "user")],
_ => &[],
}
}
pub(super) fn wechat_refresh_url(&self) -> Option<&str> {
match &self.kind {
ProviderKind::WeChat { refresh_url } => Some(refresh_url),
_ => None,
}
}
pub(super) fn is_tiktok(&self) -> bool {
matches!(self.kind, ProviderKind::TikTok)
}
pub(super) fn is_twitter(&self) -> bool {
matches!(self.kind, ProviderKind::Twitter)
}
pub(super) fn is_vk(&self) -> bool {
matches!(self.kind, ProviderKind::Vk)
}
pub(super) fn is_discord(&self) -> bool {
matches!(self.kind, ProviderKind::Discord)
}
pub(super) fn is_roblox(&self) -> bool {
matches!(self.kind, ProviderKind::Roblox)
}
pub(super) fn omits_request_nonce(&self) -> bool {
!matches!(self.kind, ProviderKind::Custom)
}
pub(super) fn is_reddit(&self) -> bool {
matches!(self.kind, ProviderKind::Reddit)
}
pub(super) fn uses_pkce(&self) -> bool {
!matches!(
self.kind,
ProviderKind::Facebook(_)
| ProviderKind::Discord
| ProviderKind::LinkedIn
| ProviderKind::Slack
| ProviderKind::Naver
| ProviderKind::Linear
| ProviderKind::Reddit
| ProviderKind::Roblox
| ProviderKind::TikTok
| ProviderKind::Notion
| ProviderKind::Twitch(_)
| ProviderKind::Kakao
| ProviderKind::Zoom { pkce: false }
| ProviderKind::WeChat { .. }
)
}
pub(super) fn forwards_code_verifier(&self) -> bool {
matches!(self.kind, ProviderKind::Zoom { .. } | ProviderKind::TikTok) || self.uses_pkce()
}
pub(super) fn omits_login_hint(&self) -> bool {
matches!(
self.kind,
ProviderKind::Apple(_)
| ProviderKind::Cognito(_)
| ProviderKind::Discord
| ProviderKind::Spotify
| ProviderKind::HuggingFace
| ProviderKind::Polar
| ProviderKind::Vercel
| ProviderKind::Figma
| ProviderKind::Dropbox
| ProviderKind::Kick
| ProviderKind::PayPal
| ProviderKind::Cloudflare
| ProviderKind::Slack
| ProviderKind::Naver
| ProviderKind::Atlassian
| ProviderKind::Reddit
| ProviderKind::Salesforce
| ProviderKind::Railway
| ProviderKind::Roblox
| ProviderKind::TikTok
| ProviderKind::Twitch(_)
| ProviderKind::Kakao
| ProviderKind::Zoom { .. }
| ProviderKind::Twitter
| ProviderKind::WeChat { .. }
| ProviderKind::Vk
)
}
pub(super) fn code_client_key(&self) -> Option<&str> {
match self.kind {
ProviderKind::Custom
| ProviderKind::PayPal
| ProviderKind::Reddit
| ProviderKind::WeChat { .. }
| ProviderKind::TikTok => None,
_ => self.client_key.as_deref(),
}
}
pub(super) fn omits_device_id(&self) -> bool {
matches!(
self.kind,
ProviderKind::Microsoft { .. }
| ProviderKind::Google { .. }
| ProviderKind::Apple(_)
| ProviderKind::Facebook(_)
| ProviderKind::Discord
| ProviderKind::Cognito(_)
| ProviderKind::PayPal
| ProviderKind::Cloudflare
| ProviderKind::LinkedIn
| ProviderKind::Line { .. }
| ProviderKind::Paybin { .. }
| ProviderKind::Slack
| ProviderKind::Naver
| ProviderKind::Linear
| ProviderKind::Atlassian
| ProviderKind::Reddit
| ProviderKind::Salesforce
| ProviderKind::Railway
| ProviderKind::Roblox
| ProviderKind::TikTok
| ProviderKind::Notion
| ProviderKind::Twitch(_)
| ProviderKind::Kakao
| ProviderKind::Zoom { .. }
| ProviderKind::Twitter
| ProviderKind::WeChat { .. }
)
}
pub(super) fn http_profile_data(
&self,
response: &FieldValue,
error: &FieldValue,
) -> AuthResult<Option<FieldValue>> {
self.kind.http_profile_data(response, error)
}
pub(super) fn is_cloudflare(&self) -> bool {
matches!(self.kind, ProviderKind::Cloudflare)
}
pub(super) fn token_endpoint_auth(&self) -> Option<&TokenEndpointAuth> {
match &self.kind {
ProviderKind::Microsoft {
token_endpoint_auth,
..
} => token_endpoint_auth.as_ref(),
ProviderKind::Cloudflare => self.token_endpoint_auth.as_ref(),
_ => None,
}
}
pub(super) fn token_authentication(
&self,
grant_type: TokenGrantType,
) -> Option<TokenEndpointSecretAuthentication> {
(self.is_paypal()
|| self.is_figma()
|| self.is_reddit()
|| self.is_twitter()
|| matches!(self.kind, ProviderKind::Railway)
|| matches!(self.kind, ProviderKind::Notion)
&& grant_type == TokenGrantType::AuthorizationCode
|| matches!(self.kind, ProviderKind::Cloudflare) && !self.client_secret.is_empty())
.then_some(TokenEndpointSecretAuthentication::Basic)
}
pub fn disable_implicit_sign_up(&self) -> bool {
self.disable_implicit_sign_up.unwrap_or(false)
}
pub fn disable_sign_up(&self) -> bool {
self.disable_sign_up.unwrap_or(false)
}
pub(super) fn google_jwks_url(&self) -> Option<&str> {
match &self.kind {
ProviderKind::Google { jwks_url, .. } => Some(jwks_url),
_ => None,
}
}
pub(in crate::plugins) fn google_client_ids(&self) -> Vec<String> {
let additional = match &self.kind {
ProviderKind::Google { options, .. } => options.additional_client_ids.as_slice(),
_ => &[],
};
std::iter::once(self.client_id.clone())
.chain(additional.iter().cloned())
.collect()
}
pub(super) fn google_hosted_domain(&self) -> Option<&str> {
self.authorization_params
.iter()
.find(|(name, _)| name == "hd")
.map(|(_, value)| value.as_str())
}
#[cfg(test)]
pub(super) fn set_google_jwks_url(&mut self, url: String) {
let ProviderKind::Google { jwks_url, .. } = &mut self.kind else {
panic!("Google fixture requires a Google provider");
};
*jwks_url = url;
}
pub(super) fn github_endpoints(&self) -> Option<(&str, &str)> {
match &self.kind {
ProviderKind::GitHub {
user_url,
emails_url,
} => Some((user_url, emails_url)),
_ => None,
}
}
pub(super) fn builtin_account_subject(
&self,
profile: &FieldValue,
) -> AuthResult<Option<FieldValue>> {
self.kind.account_subject(profile)
}
pub(super) fn uses_legacy_profile_subject(&self) -> bool {
matches!(self.kind, ProviderKind::Custom) && self.map_user_info.is_some()
}
pub(super) fn is_custom(&self) -> bool {
matches!(self.kind, ProviderKind::Custom)
}
pub(super) fn default_profile_includes_id(&self) -> bool {
self.get_user_info.is_some()
|| (self.github_endpoints().is_none() && self.map_user_info.is_some())
}
pub(super) fn decode_profile(
&self,
profile: Value,
) -> better_auth_core::AuthResult<Option<OAuthUserInfo>> {
if let Some(mapper) = self.map_user_info {
mapper(profile)
.map(Some)
.map_err(better_auth_core::AuthError::internal)
} else {
self.kind.decode_profile(profile)
}
}
pub(super) async fn native_default_profile(
&self,
profile: &FieldValue,
source: &FieldValue,
) -> AuthResult<Option<super::social_profile::ProviderProfile>> {
if self.map_user_info.is_some() {
return Ok(None);
}
self.kind
.native_default_profile(profile, source, self.map_profile_to_user.as_deref())
.await
}
pub(super) fn resolve(&self) -> Self {
let mut provider = self.clone();
if provider.get_user_info.is_some() {
provider.map_profile_to_user = None;
}
provider
}
pub(super) fn social_scopes<'a>(&'a self, request: Option<&'a [String]>) -> Vec<&'a str> {
if matches!(self.kind, ProviderKind::Zoom { .. } | ProviderKind::PayPal) {
return Vec::new();
}
let configured = self.scopes.as_deref().unwrap_or_default();
if matches!(self.kind, ProviderKind::Custom) {
return request
.unwrap_or(configured)
.iter()
.map(String::as_str)
.collect();
}
let mut scopes = if self.disable_default_scope {
Vec::new()
} else {
self.kind.scopes().to_vec()
};
let request = request.unwrap_or_default();
let (first, second) = if matches!(self.kind, ProviderKind::Discord | ProviderKind::Slack) {
(request, configured)
} else {
(configured, request)
};
scopes.extend(first.iter().chain(second).map(String::as_str));
if matches!(self.kind, ProviderKind::Cloudflare) {
let mut seen = HashSet::new();
scopes.retain(|scope| seen.insert(*scope));
}
scopes
}
pub(super) fn social_prompt(&self) -> Option<&str> {
match self.kind {
ProviderKind::Roblox => self
.prompt
.as_deref()
.filter(|value| !value.is_empty())
.or(Some("select_account consent")),
ProviderKind::Custom
| ProviderKind::Google { .. }
| ProviderKind::GitHub { .. }
| ProviderKind::Discord
| ProviderKind::Polar
| ProviderKind::PayPal
| ProviderKind::Cognito(_)
| ProviderKind::Microsoft { .. }
| ProviderKind::Paybin { .. }
| ProviderKind::Atlassian => self
.prompt
.as_deref()
.filter(|value| !value.is_empty())
.or_else(|| matches!(self.kind, ProviderKind::Discord).then_some("none")),
ProviderKind::Apple(_)
| ProviderKind::Facebook(_)
| ProviderKind::GitLab
| ProviderKind::Spotify
| ProviderKind::HuggingFace
| ProviderKind::Vercel
| ProviderKind::Figma
| ProviderKind::Dropbox
| ProviderKind::Kick
| ProviderKind::LinkedIn
| ProviderKind::Line { .. }
| ProviderKind::Slack
| ProviderKind::Naver
| ProviderKind::Linear
| ProviderKind::Reddit
| ProviderKind::Cloudflare
| ProviderKind::Salesforce
| ProviderKind::Railway
| ProviderKind::Notion
| ProviderKind::Twitch(_)
| ProviderKind::Kakao
| ProviderKind::Twitter
| ProviderKind::WeChat { .. }
| ProviderKind::Vk
| ProviderKind::TikTok
| ProviderKind::Zoom { .. } => None,
}
}
}
#[cfg(test)]
mod tests;