better-auth-api 1.0.0-alpha.5

Plugin implementations for better-auth
use std::sync::{Arc, Mutex};

use async_trait::async_trait;
use better_auth_core::{AuthError, AuthResult, FieldValue};
use serde_json::Value;

use super::fetch_user_info_for_code;
use crate::plugins::oauth::{
    OAuthProfile, OAuthProfileMapper, OAuthProvider, OAuthUserInfoHandler, OAuthUserInfoRequest,
    resolved::ResolvedProvider,
};

struct EmptyProfile(Arc<Mutex<Vec<&'static str>>>);

#[async_trait]
impl OAuthUserInfoHandler for EmptyProfile {
    async fn get_user_info_native(&self, request: OAuthUserInfoRequest) -> AuthResult<FieldValue> {
        assert_eq!(
            request.access_token.field_value().as_str(),
            Some("ordinary-profile-token")
        );
        self.0
            .lock()
            .map_err(|_| AuthError::internal("callback log poisoned"))?
            .push("custom");
        Ok(FieldValue::Null)
    }
}

#[async_trait]
impl OAuthProfileMapper for EmptyProfile {
    async fn map_profile(&self, _: &Value) -> AuthResult<OAuthProfile> {
        self.0
            .lock()
            .map_err(|_| AuthError::internal("callback log poisoned"))?
            .push("mapper");
        Err(AuthError::internal("custom user info must skip mapper"))
    }
}

#[tokio::test]
async fn custom_null_skips_default_http_and_mapper() -> AuthResult<()> {
    let constructors = [
        OAuthProvider::google,
        OAuthProvider::github,
        OAuthProvider::discord,
        OAuthProvider::gitlab,
        OAuthProvider::spotify,
        OAuthProvider::huggingface,
        OAuthProvider::polar,
        OAuthProvider::vercel,
        OAuthProvider::figma,
        OAuthProvider::dropbox,
        OAuthProvider::kick,
        OAuthProvider::cloudflare,
        OAuthProvider::atlassian,
        OAuthProvider::kakao,
    ];
    for constructor in constructors {
        let calls = Arc::new(Mutex::new(Vec::new()));
        let callback = Arc::new(EmptyProfile(calls.clone()));
        let mut config = constructor("client", "secret");
        config.get_user_info = Some(callback.clone());
        config.map_profile_to_user = Some(callback);
        config.user_info_url = None;
        let provider = ResolvedProvider {
            config,
            generic: None,
        };
        let profile_request = OAuthUserInfoRequest {
            access_token: (Some("ordinary-profile-token".into())).into(),
            ..Default::default()
        };
        let result = fetch_user_info_for_code(&provider, profile_request, None).await?;
        assert!(result.value.is_null());
        assert_eq!(
            *calls
                .lock()
                .map_err(|_| AuthError::internal("callback log poisoned"))?,
            ["custom"]
        );
    }
    Ok(())
}

#[expect(
    clippy::indexing_slicing,
    reason = "These fixtures index known JSON keys and read lengths returned for the same buffer."
)]
mod discord {
    use super::*;
    use serde_json::json;
    use tokio::io::{AsyncReadExt, AsyncWriteExt};

    struct Mapper {
        expected: Value,
        events: Arc<Mutex<Vec<&'static str>>>,
    }

    #[async_trait]
    impl OAuthProfileMapper for Mapper {
        async fn map_profile(&self, profile: &Value) -> AuthResult<OAuthProfile> {
            assert_eq!(profile, &self.expected);
            self.events
                .lock()
                .map_err(|_| AuthError::internal("callback log poisoned"))?
                .push("map");
            Ok(OAuthProfile::default())
        }
    }

    #[tokio::test]
    async fn normal_avatar_and_name_fields_are_prepared_before_the_mapper()
    -> Result<(), Box<dyn std::error::Error>> {
        let png = "https://cdn.discordapp.com/avatars/123456789/portrait.png";
        let cases = [
            ("static PNG", json!({}), png, "Owner"),
            (
                "animated GIF",
                json!({"avatar":"a_portrait"}),
                "https://cdn.discordapp.com/avatars/123456789/a_portrait.gif",
                "Owner",
            ),
            (
                "migrated default",
                json!({"avatar":null,"discriminator":"0"}),
                "https://cdn.discordapp.com/embed/avatars/5.png",
                "Owner",
            ),
            (
                "legacy default",
                json!({"avatar":null,"discriminator":"1234"}),
                "https://cdn.discordapp.com/embed/avatars/4.png",
                "Owner",
            ),
            (
                "global name",
                json!({"global_name":"Global Owner"}),
                png,
                "Global Owner",
            ),
            ("empty global name", json!({"global_name":""}), png, "Owner"),
            (
                "empty names",
                json!({"global_name":"","username":""}),
                png,
                "",
            ),
        ];
        for (label, patch, image, name) in cases {
            let mut profile = json!({"id":"123456789","email":"owner@example.test","username":"Owner","avatar":"portrait","verified":true});
            profile
                .as_object_mut()
                .ok_or("profile fixture must be an object")?
                .extend(
                    patch
                        .as_object()
                        .ok_or("patch fixture must be an object")?
                        .clone(),
                );
            let mut prepared = profile.clone();
            prepared["image_url"] = json!(image);
            let events = Arc::new(Mutex::new(Vec::new()));
            let server_events = events.clone();
            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
            let endpoint = format!("http://{}/profile", listener.local_addr()?);
            let server = tokio::spawn(async move {
                let (mut stream, _) = listener.accept().await?;
                let mut request = Vec::new();
                while !request.windows(4).any(|part| part == b"\r\n\r\n") {
                    let mut buffer = [0; 1024];
                    let read = stream.read(&mut buffer).await?;
                    if read == 0 {
                        return Err(std::io::Error::from(std::io::ErrorKind::UnexpectedEof));
                    }
                    request.extend_from_slice(&buffer[..read]);
                }
                let request = String::from_utf8(request).map_err(std::io::Error::other)?;
                assert!(request.starts_with("GET /profile HTTP/1.1\r\n"));
                assert!(
                    request
                        .to_ascii_lowercase()
                        .contains("authorization: bearer ordinary-profile-token\r\n")
                );
                server_events
                    .lock()
                    .map_err(|_| std::io::Error::other("callback log poisoned"))?
                    .push("http");
                let body = profile.to_string();
                let response = format!(
                    "HTTP/1.1 200 OK\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?;
                Ok::<(), std::io::Error>(())
            });
            let mut config = OAuthProvider::discord("client", "secret");
            config.user_info_url = Some(endpoint);
            config.map_profile_to_user = Some(Arc::new(Mapper {
                expected: prepared.clone(),
                events: events.clone(),
            }));
            let provider = ResolvedProvider {
                config,
                generic: None,
            };
            let profile_request = OAuthUserInfoRequest {
                access_token: (Some("ordinary-profile-token".into())).into(),
                ..Default::default()
            };
            let response = fetch_user_info_for_code(&provider, profile_request, None).await?;
            server.await??;
            assert_eq!(
                crate::plugins::oauth::profile_test_support::json(response),
                json!({
                    "user": {
                        "name": name,
                        "image": image,
                        "email": "owner@example.test",
                        "emailVerified": true
                    },
                    "data": prepared
                }),
                "{label}"
            );
            assert_eq!(
                *events
                    .lock()
                    .map_err(|_| AuthError::internal("callback log poisoned"))?,
                ["http", "map"],
                "{label}"
            );
        }
        Ok(())
    }
}