mod discord;
mod generic;
mod github;
mod gitlab;
mod google;
pub(super) mod remaining_profile;
pub use generic::{
GenericOAuthConfig, GenericOAuthError, GenericOAuthMetadata, GenericOAuthResolved,
};
mod roblox;
pub use roblox::RobloxOptions;
mod salesforce;
pub use salesforce::{SalesforceEnvironment, SalesforceOptions};
mod slack;
pub use slack::SlackOptions;
mod spotify;
pub use spotify::SpotifyOptions;
mod tiktok;
pub use tiktok::TikTokOptions;
mod twitch;
pub use twitch::TwitchOptions;
mod twitter;
pub use twitter::TwitterOptions;
mod vercel;
pub use vercel::VercelOptions;
mod vk;
pub use vk::VkOptions;
mod wechat;
pub use wechat::{WeChatLanguage, WeChatOptions};
mod zoom;
pub use zoom::ZoomOptions;
mod reddit;
pub use reddit::RedditOptions;
mod railway;
pub use railway::RailwayOptions;
mod paypal;
pub use paypal::{PayPalEnvironment, PayPalOptions};
mod paybin;
pub use paybin::PaybinOptions;
mod polar;
pub use polar::PolarOptions;
mod notion;
pub use notion::NotionOptions;
mod cloudflare;
pub use cloudflare::CloudflareOptions;
mod cognito;
pub use cognito::CognitoOptions;
mod dropbox;
pub use dropbox::{DropboxAccessType, DropboxOptions};
mod figma;
pub use figma::FigmaOptions;
mod facebook;
pub use facebook::FacebookOptions;
mod atlassian;
pub use atlassian::AtlassianOptions;
mod apple;
pub use apple::AppleOptions;
mod microsoft;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
pub use microsoft::{MicrosoftOptions, MicrosoftProfilePhotoSize};
use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct OAuthEndSessionConfig {
pub endpoint: String,
pub post_logout_redirect_uri: Option<String>,
}
#[derive(Clone, Default)]
pub struct OAuthConfig {
pub providers: HashMap<String, OAuthProvider>,
}
#[derive(Debug, Clone, Default)]
pub struct OAuthTokenSet {
pub token_type: Option<String>,
pub access_token: Option<String>,
pub refresh_token: Option<String>,
pub access_token_expires_at: Option<DateTime<Utc>>,
pub refresh_token_expires_at: Option<DateTime<Utc>>,
pub scopes: Vec<String>,
pub id_token: Option<String>,
pub raw: Option<Value>,
}
#[derive(Debug, Clone)]
pub struct OAuthUserInfo {
pub additional_fields: alibi_core::field_policy::FieldOutput,
pub id: String,
pub email: String,
pub name: Option<String>,
pub image: Option<String>,
pub email_verified: bool,
}
impl OAuthUserInfo {
#[must_use]
pub fn public_profile(&self, include_id: bool) -> alibi_core::field_policy::FieldOutput {
let mut output = self.additional_fields.clone();
_ = output.insert("email".into(), Value::String(self.email.clone()));
_ = output.insert("emailVerified".into(), Value::Bool(self.email_verified));
if include_id {
_ = output.insert("id".into(), Value::String(self.id.clone()));
}
if let Some(name) = &self.name {
_ = output.insert("name".into(), Value::String(name.clone()));
}
if let Some(image) = &self.image {
_ = output.insert("image".into(), Value::String(image.clone()));
}
output
}
}
#[derive(Debug, Clone, Default, serde::Serialize, Deserialize)]
pub struct OAuthCallbackUserPayload {
pub name: Option<OAuthCallbackUserName>,
pub email: Option<String>,
}
#[derive(Debug, Clone, Default, serde::Serialize, 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 token_type: Option<String>,
pub access_token: Option<String>,
pub refresh_token: Option<String>,
pub access_token_expires_at: Option<DateTime<Utc>>,
pub refresh_token_expires_at: Option<DateTime<Utc>>,
pub scopes: Vec<String>,
pub id_token: Option<String>,
pub raw: Option<Value>,
pub user: Option<OAuthCallbackUserPayload>,
}
#[derive(Debug, Clone)]
pub struct OAuthUserInfoResponse {
pub user_output: Option<alibi_core::field_policy::FieldOutput>,
pub user: OAuthUserInfo,
pub data: Value,
}
pub type OAuthAccountSubject = fn(&Value) -> Result<String, String>;
#[async_trait]
pub trait OAuthUserInfoHandler: Send + Sync {
fn configured_client_ids(&self, _ids: &[String]) -> Option<Arc<dyn OAuthUserInfoHandler>> {
None
}
fn errors_are_exceptions(&self) -> bool {
true
}
fn mapped_handler(
&self,
_mapper: Arc<dyn OAuthProfileMapper>,
) -> Option<Arc<dyn OAuthUserInfoHandler>> {
None
}
async fn get_user_info(
&self,
request: OAuthUserInfoRequest,
) -> Result<OAuthUserInfoResponse, String>;
}
#[async_trait]
pub trait OAuthProfileMapper: Send + Sync {
async fn map_profile(
&self,
profile: Value,
) -> Result<alibi_core::field_policy::FieldOutput, String>;
}
pub(super) fn apply_application_mapping(
response: &mut OAuthUserInfoResponse,
mapped: alibi_core::field_policy::FieldOutput,
) -> Result<(), String> {
let output = response
.user_output
.get_or_insert_with(|| response.user.public_profile(true));
output.extend(mapped.clone());
for (key, value) in mapped {
match key.as_str() {
"id" => response.user.id = remaining_profile::js_string(&value)?,
"email" => response.user.email = value.as_str().unwrap_or_default().into(),
"emailVerified" => response.user.email_verified = remaining_profile::truthy(&value),
"name" => {
response.user.name = remaining_profile::scalar(
Some(&value).filter(|value| remaining_profile::truthy(value)),
)?;
}
"image" => response.user.image = remaining_profile::scalar(Some(&value))?,
_ => {
_ = response.user.additional_fields.insert(key, value);
}
}
}
Ok(())
}
#[derive(Clone, Copy)]
pub struct OAuthRefreshContext<'a> {
pub request: &'a alibi_core::AuthRequest,
}
#[async_trait]
pub trait OAuthRefreshTokenParamsResolver: Send + Sync {
async fn resolve(
&self,
context: Option<OAuthRefreshContext<'_>>,
) -> Result<Option<std::collections::BTreeMap<String, String>>, String>;
}
#[derive(Clone)]
pub struct OAuthRefreshTokenParams(pub Arc<dyn OAuthRefreshTokenParamsResolver>);
impl std::fmt::Debug for OAuthRefreshTokenParams {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("OAuthRefreshTokenParams(..)")
}
}
#[async_trait]
pub trait OAuthRefreshTokenHandler: Send + Sync {
fn configured_client_ids(&self, _ids: &[String]) -> Option<Arc<dyn OAuthRefreshTokenHandler>> {
None
}
async fn refresh_access_token(&self, refresh_token: &str) -> Result<OAuthTokenSet, String>;
async fn refresh_access_token_with_context(
&self,
refresh_token: &str,
_context: Option<OAuthRefreshContext<'_>>,
) -> Result<OAuthTokenSet, String> {
self.refresh_access_token(refresh_token).await
}
}
#[async_trait]
pub trait OAuthIdTokenVerifier: Send + Sync {
async fn verify_id_token(&self, token: &str, nonce: Option<&str>) -> Result<bool, String>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OAuthTokenGrant {
AuthorizationCode,
RefreshToken,
}
#[derive(Debug, Clone)]
pub struct OAuthClientAssertionContext {
pub client_id: String,
pub token_endpoint: String,
pub grant_type: OAuthTokenGrant,
}
#[async_trait]
pub trait OAuthClientAssertionGetter: Send + Sync {
async fn get_client_assertion(
&self,
context: OAuthClientAssertionContext,
) -> Result<String, String>;
}
#[derive(Clone)]
pub struct OAuthClientAssertion(pub Arc<dyn OAuthClientAssertionGetter>);
impl std::fmt::Debug for OAuthClientAssertion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OAuthClientAssertion")
.finish_non_exhaustive()
}
}
#[derive(Clone)]
#[expect(
clippy::struct_excessive_bools,
reason = "Preserve independent public provider policy switches"
)]
pub struct OAuthProvider {
pub client_id: String,
pub additional_client_ids: Vec<String>,
pub hosted_domain: Option<String>,
pub require_email_verification: bool,
pub client_secret: String,
pub auth_url: String,
pub token_url: String,
pub user_info_url: Option<String>,
pub scopes: Vec<String>,
pub authorization: Option<OAuthAuthorizationPolicy>,
pub authorization_params: Vec<(String, String)>,
pub allowed_request_params: Vec<String>,
pub account_subject: Option<OAuthAccountSubject>,
pub map_user_info: Option<fn(Value) -> Result<OAuthUserInfo, String>>,
pub get_user_info: Option<Arc<dyn OAuthUserInfoHandler>>,
pub refresh_access_token: Option<Arc<dyn OAuthRefreshTokenHandler>>,
pub verify_id_token: Option<Arc<dyn OAuthIdTokenVerifier>>,
pub id_token: Option<super::id_token::OAuthIdTokenConfig>,
pub disable_id_token_sign_in: bool,
pub disable_implicit_sign_up: bool,
pub disable_sign_up: bool,
pub allow_idp_initiated: bool,
pub override_user_info_on_sign_in: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OAuthScopeOrder {
ConfiguredThenRequested,
RequestedThenConfigured,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum OAuthScopeEncoding {
#[default]
Form,
UriComponent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OAuthTokenEndpointAuth {
ClientSecretBasic,
ClientSecretPost,
PrivateKeyJwt,
None,
ClientKeyPost,
}
#[derive(Debug, Clone)]
pub struct OAuthAuthorizationCodeContext {
pub code: String,
pub redirect_uri: String,
pub code_verifier: Option<String>,
pub device_id: Option<String>,
}
#[async_trait]
pub trait OAuthAuthorizationCodeHandler: Send + Sync {
fn configured_client_ids(
&self,
_ids: &[String],
) -> Option<Arc<dyn OAuthAuthorizationCodeHandler>> {
None
}
async fn validate_authorization_code(
&self,
context: OAuthAuthorizationCodeContext,
) -> Result<OAuthTokenSet, String>;
}
#[derive(Clone)]
pub struct OAuthAuthorizationCodeCallback(pub Arc<dyn OAuthAuthorizationCodeHandler>);
impl std::fmt::Debug for OAuthAuthorizationCodeCallback {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("OAuthAuthorizationCodeCallback(..)")
}
}
#[derive(Debug, Clone)]
pub struct OAuthAccountKeyContext {
pub tokens: OAuthTokenSet,
pub profile: Value,
}
#[async_trait]
pub trait OAuthAccountKeyResolver: Send + Sync {
async fn resolve(&self, context: OAuthAccountKeyContext) -> Result<Value, String>;
}
#[derive(Clone)]
pub struct OAuthAccountKey(pub Arc<dyn OAuthAccountKeyResolver>);
impl std::fmt::Debug for OAuthAccountKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OAuthAccountKey").finish_non_exhaustive()
}
}
#[derive(Debug, Clone)]
pub struct OAuthAuthorizationPolicy {
pub account_key: Option<OAuthAccountKey>,
pub callback_path: Option<String>,
pub disable_sign_up_option: Option<bool>,
pub issuer: Option<String>,
pub end_session: Option<OAuthEndSessionConfig>,
pub verify_grant_id_token: bool,
pub id_token_nonce_binding: bool,
pub discovery_openid_scope: bool,
pub authorization_code: Option<OAuthAuthorizationCodeCallback>,
pub authorization_code_pkce: Option<bool>,
pub client_id_parameter: String,
pub literal_client_id: Option<String>,
pub token_expiry_always: bool,
pub token_response_omits_id_token: bool,
pub scope_separator: String,
pub emit_empty_scope: bool,
pub authorization_fragment: Option<String>,
pub supports_refresh: bool,
pub supports_profile_mapper: bool,
pub allow_missing_access_token: bool,
pub default_access_token_expires_in: Option<f64>,
pub configured_scopes: Vec<String>,
pub omit_scopes: bool,
pub scope_encoding: OAuthScopeEncoding,
pub deduplicate_scopes: bool,
pub require_client_id: bool,
pub token_endpoint_auth: Option<OAuthTokenEndpointAuth>,
pub refresh_token_endpoint_auth: Option<OAuthTokenEndpointAuth>,
pub fixed_authorization_params: Vec<(String, String)>,
pub authorization_code_client_key: Option<String>,
pub authorization_code_headers: Vec<(String, String)>,
pub authorization_code_params: std::collections::BTreeMap<String, String>,
pub refresh_token_params: std::collections::BTreeMap<String, String>,
pub refresh_token_params_resolver: Option<OAuthRefreshTokenParams>,
pub client_assertion: Option<OAuthClientAssertion>,
pub refresh_scope: Option<String>,
pub response_type: String,
pub redirect_uri: Option<String>,
pub response_mode: Option<String>,
pub require_client_secret: bool,
pub login_hint: bool,
pub disable_default_scopes: bool,
pub scope_order: OAuthScopeOrder,
pub pkce: bool,
pub prompt: Option<String>,
pub default_prompt: Option<String>,
pub discord_permissions: Option<f64>,
pub propagate_grant_profile_errors: bool,
pub preserve_raw_email_errors: bool,
pub preserve_raw_profile_scalars: bool,
pub honor_factory_options: bool,
pub source_profile_exceptions: bool,
}
impl Default for OAuthAuthorizationPolicy {
fn default() -> Self {
Self {
account_key: None,
callback_path: None,
disable_sign_up_option: None,
issuer: None,
end_session: None,
verify_grant_id_token: false,
id_token_nonce_binding: false,
discovery_openid_scope: false,
authorization_code: None,
authorization_code_pkce: None,
client_id_parameter: "client_id".into(),
literal_client_id: None,
token_expiry_always: false,
token_response_omits_id_token: false,
scope_separator: " ".into(),
emit_empty_scope: false,
authorization_fragment: None,
supports_refresh: true,
supports_profile_mapper: true,
default_access_token_expires_in: None,
allow_missing_access_token: false,
configured_scopes: Vec::new(),
omit_scopes: false,
scope_encoding: OAuthScopeEncoding::Form,
deduplicate_scopes: false,
require_client_id: false,
token_endpoint_auth: None,
refresh_token_endpoint_auth: None,
fixed_authorization_params: Vec::new(),
authorization_code_client_key: None,
authorization_code_headers: Vec::new(),
authorization_code_params: std::collections::BTreeMap::new(),
refresh_token_params: std::collections::BTreeMap::new(),
refresh_token_params_resolver: None,
client_assertion: None,
refresh_scope: None,
response_type: "code".into(),
redirect_uri: None,
response_mode: None,
require_client_secret: false,
login_hint: true,
disable_default_scopes: false,
scope_order: OAuthScopeOrder::ConfiguredThenRequested,
pkce: true,
prompt: None,
default_prompt: None,
discord_permissions: None,
propagate_grant_profile_errors: false,
preserve_raw_email_errors: false,
preserve_raw_profile_scalars: false,
honor_factory_options: true,
source_profile_exceptions: false,
}
}
}
impl OAuthProvider {
#[must_use]
pub fn with_profile_mapper(mut self, mapper: Arc<dyn OAuthProfileMapper>) -> Self {
if self
.authorization
.as_ref()
.is_none_or(|policy| policy.supports_profile_mapper)
&& let Some(handler) = self.get_user_info.take()
{
self.get_user_info = Some(handler.mapped_handler(mapper).unwrap_or(handler));
}
self
}
#[must_use]
pub fn with_client_ids(mut self, client_ids: Vec<String>) -> Self {
if let Some(handler) = self
.get_user_info
.as_ref()
.and_then(|handler| handler.configured_client_ids(&client_ids))
{
self.get_user_info = Some(handler);
}
if let Some(handler) = self
.refresh_access_token
.as_ref()
.and_then(|handler| handler.configured_client_ids(&client_ids))
{
self.refresh_access_token = Some(handler);
}
if let Some(policy) = self.authorization.as_mut() {
if policy.client_id_parameter == "appid" {
policy.literal_client_id = Some(client_ids.join(","));
}
if let Some(handler) = policy
.authorization_code
.as_ref()
.and_then(|callback| callback.0.configured_client_ids(&client_ids))
{
policy.authorization_code = Some(OAuthAuthorizationCodeCallback(handler));
}
}
if let Some(policy) = self.id_token.as_mut() {
policy.client_ids = Some(client_ids.clone());
}
let mut ids = client_ids.into_iter();
self.client_id = ids.next().unwrap_or_default();
self.additional_client_ids = ids.collect();
self
}
#[must_use]
pub const fn require_email_verification(mut self, required: bool) -> Self {
self.require_email_verification = required;
self
}
}
impl std::fmt::Debug for OAuthConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OAuthConfig").finish_non_exhaustive()
}
}
impl std::fmt::Debug for OAuthProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OAuthProvider").finish_non_exhaustive()
}
}
mod huggingface;
mod kakao;
mod kick;
mod line;
pub use huggingface::HuggingFaceOptions;
pub use kakao::KakaoOptions;
pub use kick::KickOptions;
pub use line::LineOptions;
mod naver;
pub use naver::NaverOptions;
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::Mutex;
async fn start_github_mock_server(
profile: Value,
emails: Value,
) -> (String, String, Arc<Mutex<Vec<String>>>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let requests = Arc::new(Mutex::new(Vec::new()));
let captured_requests = std::sync::Arc::clone(&requests);
tokio::spawn(async move {
loop {
let Ok((mut stream, _)) = listener.accept().await else {
break;
};
let profile = profile.clone();
let emails = emails.clone();
let requests_2 = std::sync::Arc::clone(&captured_requests);
tokio::spawn(async move {
let mut buffer = vec![0u8; 4096];
let read = stream.read(&mut buffer).await.unwrap_or(0);
let request = String::from_utf8_lossy(
(buffer)
.get(..read)
.expect("fixture contains the requested index"),
)
.to_string();
requests_2.lock().await.push(request.clone());
let (status, body) = if request.contains("/user/emails") {
("200 OK", emails.to_string())
} else if request.contains("/user") {
("200 OK", profile.to_string())
} else {
(
"404 Not Found",
serde_json::json!({ "error": "not found" }).to_string(),
)
};
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len(),
);
_ = stream.write_all(response.as_bytes()).await;
_ = stream.flush().await;
});
}
});
let base_url = format!("http://127.0.0.1:{}", addr.port());
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
(
format!("{base_url}/user"),
format!("{base_url}/user/emails"),
requests,
)
}
#[test]
fn github_provider_uses_ts_default_scopes() {
let provider = OAuthProvider::github("github-client-id", "github-client-secret");
assert_eq!(
provider.scopes,
vec!["read:user".to_owned(), "user:email".to_owned()]
);
assert!(provider.get_user_info.is_some());
assert!(provider.map_user_info.is_none());
}
#[tokio::test]
async fn github_provider_get_user_info_uses_email_fallback_and_login_name() {
let (user_url, emails_url, requests) = start_github_mock_server(
serde_json::json!({
"id": 42,
"login": "octocat",
"name": null,
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/42?v=4",
}),
serde_json::json!([
{
"email": "octocat@example.com",
"primary": true,
"verified": true,
"visibility": "private"
},
{
"email": "secondary@example.com",
"primary": false,
"verified": false,
"visibility": "private"
}
]),
)
.await;
let provider = OAuthProvider::github_with_endpoints(
"github-client-id",
"github-client-secret",
"https://github.com/login/oauth/authorize",
"https://github.com/login/oauth/access_token",
&user_url,
&emails_url,
);
let handler = provider.get_user_info.as_ref().unwrap();
let response = handler
.get_user_info(OAuthUserInfoRequest {
access_token: Some("github-access-token".to_owned()),
..Default::default()
})
.await
.unwrap();
assert_eq!(response.user.id, "42");
assert_eq!(response.user.email, "octocat@example.com");
assert_eq!(response.user.name.as_deref(), Some("octocat"));
assert_eq!(
response.user.image.as_deref(),
Some("https://avatars.githubusercontent.com/u/42?v=4")
);
assert!(response.user.email_verified);
assert_eq!(
(*(response.data)
.get("email")
.expect("fixture contains the requested index")),
serde_json::json!("octocat@example.com")
);
let requests = requests.lock().await;
assert_eq!(requests.len(), 2);
for request in requests.iter() {
let lowered = request.to_ascii_lowercase();
assert!(lowered.contains("authorization: bearer github-access-token"));
assert!(lowered.contains("user-agent: better-auth"));
}
}
#[tokio::test]
async fn github_provider_get_user_info_keeps_inline_email() {
let (user_url, emails_url, _) = start_github_mock_server(
serde_json::json!({
"id": "github-inline-email",
"login": "octocat",
"name": "Octo Cat",
"email": "public@example.com",
"avatar_url": null,
}),
serde_json::json!([
{
"email": "primary@example.com",
"primary": true,
"verified": true,
"visibility": "private"
},
{
"email": "public@example.com",
"primary": false,
"verified": false,
"visibility": "public"
}
]),
)
.await;
let provider = OAuthProvider::github_with_endpoints(
"github-client-id",
"github-client-secret",
"https://github.com/login/oauth/authorize",
"https://github.com/login/oauth/access_token",
&user_url,
&emails_url,
);
let handler = provider.get_user_info.as_ref().unwrap();
let response = handler
.get_user_info(OAuthUserInfoRequest {
access_token: Some("github-access-token".to_owned()),
..Default::default()
})
.await
.unwrap();
assert_eq!(response.user.email, "public@example.com");
assert_eq!(response.user.name.as_deref(), Some("Octo Cat"));
assert!(!response.user.email_verified);
}
}
mod linkedin;
pub use linkedin::LinkedInOptions;
mod linear;
pub use linear::LinearOptions;