better-auth-api 1.0.0-alpha.5

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

use async_trait::async_trait;
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use better_auth_core::{AuthPlugin, AuthRequest, AuthResult, AuthUser, HttpMethod};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;

use super::{
    GoogleOptions, OAuthConfig, OAuthPlugin, OAuthProfile, OAuthProfileMapper, OAuthProvider,
    authorization::{AuthorizationRequest, build_authorization_url},
    google_test_support::GoogleFixture,
    resolved::ResolvedProvider,
};
use crate::plugins::{
    one_tap::{OneTapConfig, OneTapPlugin},
    test_helpers,
};

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

fn fixture() -> TestResult<Value> {
    Ok(serde_json::from_str(&std::fs::read_to_string(concat!(
        env!("CARGO_MANIFEST_DIR"),
        "/../../tests/fixtures/google-client-ids-1.7.6.json"
    ))?)?)
}

fn string<'a>(value: &'a Value, key: &str) -> TestResult<&'a str> {
    value[key]
        .as_str()
        .ok_or_else(|| format!("missing Google fixture string: {key}").into())
}

fn provider(metadata: &Value) -> TestResult<OAuthProvider> {
    let mut provider = OAuthProvider::google_with_options(
        string(metadata, "clientId")?,
        string(metadata, "clientSecret")?,
        GoogleOptions {
            additional_client_ids: serde_json::from_value(metadata["additionalClientIds"].clone())?,
        },
    );
    provider
        .authorization_params
        .push(("hd".into(), string(&metadata["profile"], "hd")?.into()));
    Ok(provider)
}

#[tokio::test]
async fn google_client_ids_keep_primary_authorization_and_grant_requests() -> TestResult<()> {
    let fixture = fixture()?;
    let metadata = &fixture["metadata"];
    let verifier = string(metadata, "codeVerifier")?;
    let callback = string(metadata, "callbackURL")?;
    let configured = provider(metadata)?;
    let resolved = ResolvedProvider {
        config: configured.resolve(),
        generic: None,
    };
    let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
    let url = build_authorization_url(
        &resolved,
        AuthorizationRequest {
            callback_url: callback,
            state: "ordinary-state",
            code_challenge: &challenge,
            scopes: None,
            login_hint: None,
            nonce: None,
            additional_params: None,
        },
    )?;
    let url = url::Url::parse(&url)?;
    let query: serde_json::Map<String, Value> = url
        .query_pairs()
        .map(|(key, value)| (key.into_owned(), Value::String(value.into_owned())))
        .collect();
    assert_eq!(
        json!({"origin": url.origin().ascii_serialization(), "path": url.path(), "query": query}),
        fixture["authorization"]
    );
    let (requests, tokens) = super::social_token_wire_tests::grants(
        configured,
        callback,
        verifier,
        fixture["grants"]["rawResponse"].clone(),
    )
    .await?;
    assert_eq!(json!(requests), fixture["grants"]["requests"]);
    assert_eq!(tokens, fixture["grants"]["tokens"]);
    Ok(())
}

struct Mapper(Arc<Mutex<Vec<Value>>>);

#[async_trait]
impl OAuthProfileMapper for Mapper {
    async fn map_profile(&self, profile: &Value) -> AuthResult<OAuthProfile> {
        self.0.lock().await.push(json!({
            "sub": profile["sub"], "aud": profile["aud"],
            "name": profile["name"], "hd": profile["hd"],
        }));
        Ok(OAuthProfile {
            name: Some(Some("Mapped Google Owner".into()).into()),
            ..Default::default()
        })
    }
}

fn post(path: &str, body: Value) -> TestResult<AuthRequest> {
    let mut request = AuthRequest::new(HttpMethod::Post, path);
    request.body = Some(serde_json::to_vec(&body)?);
    Ok(request)
}

#[tokio::test]
async fn google_client_ids_sign_in_and_one_tap_match_pinned_successes() -> TestResult<()> {
    let fixture = fixture()?;
    let metadata = &fixture["metadata"];
    for sample in fixture["flows"]
        .as_array()
        .ok_or("missing Google flow cases")?
    {
        let mode = string(sample, "mode")?;
        let mut claims = metadata["profile"].clone();
        claims["aud"] = sample["audience"].clone();
        claims["nonce"] = json!("ordinary-nonce");
        let server = GoogleFixture::start(claims).await;
        let inputs = Arc::new(Mutex::new(Vec::new()));
        let mut configured = provider(metadata)?;
        server.configure(&mut configured);
        configured.map_profile_to_user = Some(Arc::new(Mapper(inputs.clone())));
        let mut ctx = test_helpers::create_test_context().await;
        let mut oauth_config = OAuthConfig::default();
        let _ = oauth_config.providers.insert("google".into(), configured);
        ctx.extensions.insert(oauth_config.clone());
        let plugin = OAuthPlugin::with_config(oauth_config);
        let response = if mode == "oneTap" {
            let one_tap = OneTapPlugin::with_config(OneTapConfig {
                client_id: sample
                    .get("oneTapClientIds")
                    .map(|ids| serde_json::from_value(ids.clone()))
                    .transpose()?,
                google_jwks_url: format!("{}/jwks", server.url),
                ..Default::default()
            });
            one_tap
                .on_request(
                    &post("/one-tap/callback", json!({"idToken":server.token}))?,
                    &ctx,
                )
                .await?
                .ok_or("missing One Tap response")?
        } else if mode == "direct" {
            plugin.on_request(&post("/sign-in/social", json!({
                "provider":"google", "idToken":{"token":server.token, "nonce":"ordinary-nonce"}
            }))?, &ctx).await?.ok_or("missing direct response")?
        } else {
            assert_eq!(mode, "code");
            let start = plugin.on_request(&post("/sign-in/social", json!({
                "provider":"google", "callbackURL":"http://localhost:3000/welcome", "disableRedirect":true
            }))?, &ctx).await?.ok_or("missing code start")?;
            assert_eq!(start.status, 200);
            let body: Value = serde_json::from_slice(&start.body.bytes()?)?;
            let url = url::Url::parse(string(&body, "url")?)?;
            let state = url
                .query_pairs()
                .find(|(key, _)| key == "state")
                .ok_or("missing state")?
                .1
                .into_owned();
            let cookie = start
                .headers
                .get_all("Set-Cookie")
                .filter_map(|value| value.split(';').next())
                .collect::<Vec<_>>()
                .join("; ");
            assert!(!cookie.is_empty());
            let mut request = AuthRequest::new(HttpMethod::Get, "/callback/google");
            let _ = request.headers.insert("cookie".into(), cookie);
            request.query = Some(json!({"code":"ordinary-code", "state":state}));
            plugin
                .on_request(&request, &ctx)
                .await?
                .ok_or("missing code callback")?
        };
        if mode == "direct" {
            let fields = response.body.field_value()?.enumerable_fields()?;
            assert_eq!(
                fields.keys().map(String::as_str).collect::<Vec<_>>(),
                ["redirect", "token", "url", "user"]
            );
            assert!(matches!(
                fields.get("url"),
                Some(better_auth_core::FieldValue::Undefined)
            ));
        }
        let user = ctx
            .database
            .get_user_by_email(string(&metadata["profile"], "email")?)
            .await?
            .ok_or("missing Google user")?;
        let user_id = user.id().display_string()?;
        let user = serde_json::to_value(user)?;
        let accounts = ctx.database.get_user_accounts(&user_id).await?;
        assert_eq!(accounts.len(), 1);
        let account = accounts.first().ok_or("missing Google account")?;
        let result = json!({
            "status": response.status, "location": response.headers.get("Location"),
            "user": {"name":user["name"], "email":user["email"], "emailVerified":user["emailVerified"], "image":user["image"]},
            "account": {"providerId":account.provider_id, "accountId":account.account_id},
            "sessions": ctx.session_manager().list_user_sessions(&user_id).await?.len(),
            "mapperInputs": *inputs.lock().await,
        });
        assert_eq!(result, sample["result"], "{mode}: {}", sample["audience"]);
        let expected_requests = if mode == "code" {
            vec!["/token", "/jwks"]
        } else {
            vec!["/jwks"]
        };
        assert_eq!(
            *server
                .requests
                .lock()
                .map_err(|_| "poisoned Google fixture log")?,
            expected_requests
        );
    }
    Ok(())
}