better-auth-api 1.0.0-alpha.5

Plugin implementations for better-auth
use std::{
    collections::BTreeMap,
    error::Error,
    sync::{Arc, mpsc},
};

use async_trait::async_trait;
use axum::{
    Json, Router,
    extract::{Query, State},
    http::{HeaderMap, Method, StatusCode, Uri},
    routing::get,
};
use better_auth_core::{AuthError, AuthResult};
use serde_json::{Value, json};

use super::authorization::{AuthorizationRequest, build_authorization_url};
use super::profile_test_support as profile_test;
use super::resolved::ResolvedProvider;
use super::social_profile::fetch_user_info_from_provider;
use super::{
    OAuthProfile, OAuthProfileMapper, OAuthProvider, OAuthTokenSet, OAuthUserInfo,
    OAuthUserInfoHandler, OAuthUserInfoRequest, OAuthUserInfoResponse,
};

type TestResult<T> = Result<T, Box<dyn Error>>;

fn fixture() -> TestResult<Value> {
    let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("../../tests/fixtures/tiktok-1.7.6.json");
    Ok(serde_json::from_str(&std::fs::read_to_string(path)?)?)
}

fn field<'a>(value: &'a Value, path: &str) -> TestResult<&'a Value> {
    value
        .pointer(path)
        .ok_or_else(|| format!("Missing TikTok fixture field {path}").into())
}

fn string<'a>(value: &'a Value, path: &str) -> TestResult<&'a str> {
    field(value, path)?
        .as_str()
        .ok_or_else(|| format!("Expected TikTok string {path}").into())
}

fn provider(fixture: &Value) -> TestResult<OAuthProvider> {
    Ok(OAuthProvider::tiktok(
        string(fixture, "/metadata/clientKey")?,
        string(fixture, "/metadata/clientSecret")?,
    ))
}

#[tokio::test]
async fn tiktok_authorization_and_grants_match_pinned_capture() -> TestResult<()> {
    let fixture = fixture()?;
    let defaults = provider(&fixture)?;
    assert!(defaults.client_id.is_empty());
    assert_eq!(
        defaults.client_key.as_deref(),
        Some(string(&fixture, "/metadata/clientKey")?)
    );
    assert_eq!(
        defaults.auth_url,
        string(&fixture, "/metadata/authorizationEndpoint")?
    );
    assert_eq!(
        defaults.token_url,
        string(&fixture, "/metadata/tokenEndpoint")?
    );
    assert_eq!(
        defaults.user_info_url.as_deref(),
        Some(string(&fixture, "/metadata/profileEndpoint")?)
    );
    assert!(!defaults.uses_pkce());
    assert!(defaults.forwards_code_verifier());
    for case in field(&fixture, "/authorization")?
        .as_array()
        .ok_or("Missing authorization cases")?
    {
        let options = field(case, "/options")?;
        let mut config = provider(&fixture)?;
        config.scopes = options
            .get("scope")
            .cloned()
            .map(serde_json::from_value)
            .transpose()?;
        config.disable_default_scope = options
            .get("disableDefaultScope")
            .and_then(Value::as_bool)
            .unwrap_or(false);
        config.redirect_uri = options
            .get("redirectURI")
            .and_then(Value::as_str)
            .map(str::to_owned);
        config.prompt = options
            .get("prompt")
            .and_then(Value::as_str)
            .map(str::to_owned);
        if let Some(endpoint) = options.get("authorizationEndpoint").and_then(Value::as_str) {
            config.auth_url = endpoint.to_owned();
        }
        let scopes: Option<Vec<String>> = case
            .get("scopes")
            .cloned()
            .map(serde_json::from_value)
            .transpose()?;
        let additional = case
            .get("additionalParams")
            .cloned()
            .map(serde_json::from_value)
            .transpose()?;
        let provider = ResolvedProvider {
            config: config.resolve(),
            generic: None,
        };
        let actual = build_authorization_url(
            &provider,
            AuthorizationRequest {
                callback_url: string(&fixture, "/metadata/callbackURL")?,
                scopes: scopes.as_deref(),
                state: "ordinary-state",
                code_challenge: "ordinary-challenge",
                login_hint: Some("reader@example.test"),
                nonce: Some("ordinary-nonce"),
                additional_params: additional.as_ref(),
            },
        )?;
        assert_eq!(actual, string(case, "/url")?, "{}", string(case, "/name")?);
    }
    for case in field(&fixture, "/grants")?
        .as_array()
        .ok_or("Missing grant cases")?
    {
        let mut config = provider(&fixture)?;
        config.client_key = Some(string(case, "/clientKey")?.to_owned());
        config.client_secret = string(case, "/clientSecret")?.to_owned();
        config.redirect_uri = case
            .get("redirectURI")
            .and_then(Value::as_str)
            .map(str::to_owned);
        let (requests, tokens) = super::social_token_wire_tests::grants(
            config,
            string(&fixture, "/metadata/callbackURL")?,
            string(&fixture, "/metadata/codeVerifier")?,
            field(&fixture, "/tokenResponse")?.clone(),
        )
        .await?;
        assert_eq!(
            json!(requests),
            *field(case, "/requests")?,
            "{}",
            string(case, "/name")?
        );
        assert_eq!(
            tokens,
            *field(case, "/tokens")?,
            "{}",
            string(case, "/name")?
        );
    }
    Ok(())
}

async fn profile(
    State((response, requests)): State<(Value, mpsc::Sender<Value>)>,
    method: Method,
    uri: Uri,
    headers: HeaderMap,
    Query(query): Query<BTreeMap<String, String>>,
) -> Result<Json<Value>, StatusCode> {
    let authorization = headers
        .get("authorization")
        .map(|value| value.to_str())
        .transpose()
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    requests.send(json!({"method":method.as_str(), "path":uri.path(), "query":query, "authorization":authorization}))
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(response))
}

struct Callbacks {
    mapper_inputs: mpsc::Sender<Value>,
    calls: mpsc::Sender<&'static str>,
}

#[async_trait]
impl OAuthProfileMapper for Callbacks {
    async fn map_profile(&self, raw: &Value) -> AuthResult<OAuthProfile> {
        self.mapper_inputs
            .send(raw.clone())
            .map_err(|error| AuthError::internal(error.to_string()))?;
        Ok(OAuthProfile {
            name: Some(Some("Mapped".to_owned()).into()),
            email: Some(Some("mapped@example.test".to_owned()).into()),
            image: Some(None),
            email_verified: Some(Some(true).into()),
            ..Default::default()
        })
    }
}

#[async_trait]
impl OAuthUserInfoHandler for Callbacks {
    async fn get_user_info(
        &self,
        request: OAuthUserInfoRequest,
    ) -> AuthResult<Option<OAuthUserInfoResponse>> {
        assert_eq!(
            request.access_token.field_value().as_str(),
            Some("ordinary-access")
        );
        self.calls
            .send("get")
            .map_err(|error| AuthError::internal(error.to_string()))?;
        Ok(Some(OAuthUserInfoResponse {
            user: OAuthUserInfo {
                id: "custom-tiktok-user".into(),
                name: Some("Custom".to_owned()).into(),
                email: Some("custom@example.test".to_owned()).into(),
                image: Some(None),
                email_verified: Some(true).into(),
                additional_fields: Default::default(),
            },
            data: json!({"data":{"user":{"open_id":"custom-tiktok-user"}}}),
        }))
    }
}

#[tokio::test]
async fn tiktok_profiles_preserve_envelope_and_skip_mapper_like_pinned_capture() -> TestResult<()> {
    let fixture = fixture()?;
    for case in field(&fixture, "/profiles")?
        .as_array()
        .ok_or("Missing profile cases")?
    {
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
        let (requests, captured) = mpsc::channel();
        let (mapper_inputs, mapped) = mpsc::channel();
        let (calls, called) = mpsc::channel();
        let mut config = provider(&fixture)?;
        let mut endpoint = url::Url::parse(string(&fixture, "/metadata/profileEndpoint")?)?;
        endpoint
            .set_scheme("http")
            .map_err(|()| "Cannot set profile scheme")?;
        endpoint.set_host(Some("127.0.0.1"))?;
        endpoint
            .set_port(Some(listener.local_addr()?.port()))
            .map_err(|()| "Cannot set profile port")?;
        config.user_info_url = Some(endpoint.to_string());
        let router = Router::new()
            .route("/v2/user/info/", get(profile))
            .with_state((field(case, "/profile")?.clone(), requests));
        let callbacks = Arc::new(Callbacks {
            mapper_inputs,
            calls,
        });
        if string(case, "/mode")? != "default" {
            config.map_profile_to_user = Some(callbacks.clone());
        }
        if string(case, "/mode")? == "custom" {
            config.get_user_info = Some(callbacks);
        }
        let provider = ResolvedProvider {
            config: config.resolve(),
            generic: None,
        };
        let explicit_accepts = if provider.config.get_user_info.is_none() {
            let request = provider
                .config
                .user_info_request(endpoint.as_str(), &"ordinary-access".into())?
                .build()?;
            vec![
                request
                    .headers()
                    .get("accept")
                    .map(|value| value.to_str())
                    .transpose()?
                    .map(str::to_owned),
            ]
        } else {
            Vec::new()
        };
        let server = tokio::spawn(async move { axum::serve(listener, router).await });
        let profile_request = OAuthUserInfoRequest {
            access_token: (Some("ordinary-access".into())).into(),
            ..Default::default()
        };
        let subject_tokens = OAuthTokenSet::from_fields(profile_request.fields());
        let response = fetch_user_info_from_provider(&provider, profile_request, None).await;
        server.abort();
        let response = response?;
        assert_eq!(
            profile_test::subject(&provider, &subject_tokens, &response)
                .await
                .json()?,
            Some(field(case, "/subject")?.clone())
        );
        assert_eq!(
            profile_test::json(response),
            *field(case, "/result")?,
            "{}",
            string(case, "/name")?
        );
        assert_eq!(
            json!(captured.try_iter().collect::<Vec<_>>()),
            *field(case, "/requests")?
        );
        assert_eq!(
            json!(mapped.try_iter().collect::<Vec<_>>()),
            *field(case, "/mapperInputs")?
        );
        assert_eq!(
            json!(called.try_iter().collect::<Vec<_>>()),
            *field(case, "/calls")?
        );
        assert_eq!(json!(explicit_accepts), *field(case, "/explicitAccepts")?);
    }
    Ok(())
}