better-auth-api 1.0.0-alpha.5

Plugin implementations for better-auth
use super::{google, google_test_support::GoogleFixture, *};
use crate::plugins::test_helpers;
use better_auth_core::{AuthPlugin, AuthUser, wire::UserView};
use serde_json::{Value, json};
use std::sync::Mutex;

fn claims() -> Value {
    json!({
        "sub":"google-normal-subject",
        "email":"google-normal@example.test",
        "email_verified":true,
        "name":"Verified Google User",
        "picture":"https://images.test/verified.png",
        "nonce":"normal-nonce",
        "hd":"example.test"
    })
}

fn expected_fixture() -> Value {
    serde_json::from_str(include_str!(
        "../../../../../tests/fixtures/google-profile-1.7.6.json"
    ))
    .unwrap()
}

fn observed(user: &Value, account: &Value, calls: &[&str]) -> Value {
    json!({"name":user["name"],"image":user["image"],"email":user["email"],"emailVerified":user["emailVerified"],"calls":calls,"accountSubject":account["accountId"]})
}

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

#[async_trait]
impl OAuthProfileMapper for Mapper {
    async fn map_profile(&self, profile: &Value) -> AuthResult<OAuthProfile> {
        self.0.lock().unwrap().push("map");
        assert_eq!(profile["name"], "Verified Google User");
        assert_eq!(profile["sub"], "google-normal-subject");
        assert_eq!(profile["iss"], "https://accounts.google.com");
        Ok(OAuthProfile {
            name: Some(Some("Mapped Google User".into()).into()),
            ..Default::default()
        })
    }
}

fn request(token: &str) -> AuthRequest {
    let mut request = AuthRequest::new(HttpMethod::Post, "/sign-in/social");
    request.body = Some(
        serde_json::to_vec(&json!({
            "provider":"google", "idToken":{"token":token,"nonce":"normal-nonce"}
        }))
        .unwrap(),
    );
    request
}

#[tokio::test]
async fn google_direct_and_code_sign_in_map_verified_claims_once() {
    for direct in [true, false] {
        let fixture = GoogleFixture::start(claims()).await;
        let calls = Arc::new(Mutex::new(Vec::new()));
        let mut provider = fixture.provider();
        provider
            .authorization_params
            .push(("hd".into(), "example.test".into()));
        provider.map_profile_to_user = Some(Arc::new(Mapper(calls.clone())));
        let plugin = OAuthPlugin::new().add_provider("google", provider);
        let mut config = test_helpers::create_test_config();
        config.account.skip_state_cookie_check = true;
        let ctx = test_helpers::create_test_context_with_config(config).await;
        if direct {
            let response = plugin
                .on_request(&request(&fixture.token), &ctx)
                .await
                .unwrap()
                .unwrap();
            assert_eq!(response.status, 200);
            let body: Value = serde_json::from_slice(&response.body.bytes().unwrap()).unwrap();
            assert_eq!(body["user"]["name"], "Mapped Google User");
            assert_eq!(body["user"]["image"], "https://images.test/verified.png");
        } else {
            let mut start = AuthRequest::new(HttpMethod::Post, "/sign-in/social");
            start.body = Some(serde_json::to_vec(&json!({
                "provider":"google","callbackURL":"http://localhost:3000/welcome","disableRedirect":true
            })).unwrap());
            let response = plugin.on_request(&start, &ctx).await.unwrap().unwrap();
            let body: Value = serde_json::from_slice(&response.body.bytes().unwrap()).unwrap();
            let url = url::Url::parse(body["url"].as_str().unwrap()).unwrap();
            let state = url
                .query_pairs()
                .find(|(name, _)| name == "state")
                .unwrap()
                .1
                .into_owned();
            let mut callback = AuthRequest::new(HttpMethod::Get, "/callback/google");
            callback.query = Some(json!({"code":"normal-code","state":state}));
            let response = plugin.on_request(&callback, &ctx).await.unwrap().unwrap();
            assert_eq!(response.status, 302);
            assert_eq!(
                response.headers.get("Location").map(String::as_str),
                Some("http://localhost:3000/welcome")
            );
        }
        let user = ctx
            .database
            .get_user_by_email("google-normal@example.test")
            .await
            .unwrap()
            .unwrap();
        let view = serde_json::to_value(UserView::from(&user)).unwrap();
        assert_eq!(view["name"], "Mapped Google User");
        assert_eq!(view["image"], "https://images.test/verified.png");
        assert_eq!(view["emailVerified"], true);
        let accounts = ctx
            .database
            .get_user_accounts(&user.id().display_string().unwrap())
            .await
            .unwrap();
        assert_eq!(accounts.len(), 1);
        assert_eq!(
            observed(
                &view,
                &serde_json::to_value(&accounts[0]).unwrap(),
                &calls.lock().unwrap()
            ),
            expected_fixture()[if direct { "direct" } else { "code" }]
        );
        assert_eq!(*calls.lock().unwrap(), ["map"]);
        assert_eq!(
            *fixture.requests.lock().unwrap(),
            if direct {
                vec!["/jwks"]
            } else {
                vec!["/token", "/jwks"]
            }
        );
    }
}

struct ConfiguredVerifier {
    endpoint: String,
    calls: Arc<Mutex<Vec<&'static str>>>,
}
#[async_trait]
impl OAuthIdTokenVerifier for ConfiguredVerifier {
    async fn verify_id_token(
        &self,
        token: &str,
        nonce: Option<&str>,
        _: Option<NativeRequest<'_>>,
    ) -> Result<bool, String> {
        self.calls.lock().unwrap().push("verify");
        Ok(
            google::verify(token, &["client".into()], nonce, &self.endpoint)
                .await
                .is_some(),
        )
    }
}
struct ConfiguredUserInfo(Arc<Mutex<Vec<&'static str>>>);
#[async_trait]
impl OAuthUserInfoHandler for ConfiguredUserInfo {
    async fn get_user_info(
        &self,
        _: OAuthUserInfoRequest,
    ) -> AuthResult<Option<OAuthUserInfoResponse>> {
        self.0.lock().unwrap().push("get");
        Ok(Some(OAuthUserInfoResponse {
            user: OAuthUserInfo {
                id: "google-normal-subject".into(),
                email: Some("google-normal@example.test".into()).into(),
                name: Some("Configured Google User".into()).into(),
                image: None,
                email_verified: Some(true).into(),
                additional_fields: Default::default(),
            },
            data: claims(),
        }))
    }
}

#[tokio::test]
async fn google_configured_verifier_keeps_claims_and_userinfo_priority() {
    for custom_userinfo in [false, true] {
        let fixture = GoogleFixture::start(claims()).await;
        let calls = Arc::new(Mutex::new(Vec::new()));
        let mut provider = fixture.provider();
        provider.verify_id_token = Some(Arc::new(ConfiguredVerifier {
            endpoint: format!("{}/jwks", fixture.url),
            calls: calls.clone(),
        }));
        provider.map_profile_to_user = Some(Arc::new(Mapper(calls.clone())));
        if custom_userinfo {
            provider.get_user_info = Some(Arc::new(ConfiguredUserInfo(calls.clone())));
        }
        let plugin = OAuthPlugin::new().add_provider("google", provider);
        let ctx = test_helpers::create_test_context().await;
        let response = plugin
            .on_request(&request(&fixture.token), &ctx)
            .await
            .unwrap()
            .unwrap();
        assert_eq!(response.status, 200);
        let body: Value = serde_json::from_slice(&response.body.bytes().unwrap()).unwrap();
        assert_eq!(
            body["user"]["name"],
            if custom_userinfo {
                "Configured Google User"
            } else {
                "Mapped Google User"
            }
        );
        assert_eq!(
            *calls.lock().unwrap(),
            if custom_userinfo {
                vec!["verify", "get"]
            } else {
                vec!["verify", "map"]
            }
        );
        assert_eq!(*fixture.requests.lock().unwrap(), ["/jwks"]);
        let user = ctx
            .database
            .get_user_by_email("google-normal@example.test")
            .await
            .unwrap()
            .unwrap();
        let accounts = ctx
            .database
            .get_user_accounts(&user.id().display_string().unwrap())
            .await
            .unwrap();
        assert_eq!(accounts.len(), 1);
        assert_eq!(
            observed(
                &serde_json::to_value(UserView::from(&user)).unwrap(),
                &serde_json::to_value(&accounts[0]).unwrap(),
                &calls.lock().unwrap()
            ),
            expected_fixture()[if custom_userinfo {
                "customUserInfo"
            } else {
                "customVerifier"
            }]
        );
    }
}

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

#[async_trait]
impl OAuthProfileMapper for FailingProfile {
    async fn map_profile(&self, profile: &Value) -> AuthResult<OAuthProfile> {
        assert_eq!(profile["sub"], "google-normal-subject");
        self.0.lock().unwrap().push("map");
        Err(better_auth_core::AuthError::internal(
            "Ordinary profile mapper failed",
        ))
    }
}

#[async_trait]
impl OAuthUserInfoHandler for FailingProfile {
    async fn get_user_info(
        &self,
        _: OAuthUserInfoRequest,
    ) -> AuthResult<Option<OAuthUserInfoResponse>> {
        self.0.lock().unwrap().push("custom");
        Err(better_auth_core::AuthError::internal(
            "Ordinary custom userinfo failed",
        ))
    }
}

#[tokio::test]
async fn verified_direct_sign_in_preserves_mapper_and_custom_handler_errors() {
    for custom in [false, true] {
        let fixture = GoogleFixture::start(claims()).await;
        let calls = Arc::new(Mutex::new(Vec::new()));
        let callbacks = Arc::new(FailingProfile(calls.clone()));
        let mut provider = fixture.provider();
        provider.map_profile_to_user = Some(callbacks.clone());
        if custom {
            provider.get_user_info = Some(callbacks);
        }
        let plugin = OAuthPlugin::new().add_provider("google", provider);
        let ctx = test_helpers::create_test_context().await;
        let error = plugin
            .on_request(&request(&fixture.token), &ctx)
            .await
            .unwrap_err();
        let expected = if custom {
            "Ordinary custom userinfo failed"
        } else {
            "Ordinary profile mapper failed"
        };
        assert!(
            matches!(&error, better_auth_core::AuthError::Internal(message) if message == expected)
        );
        let response = error.to_http_response().unwrap();
        assert_eq!(response.status, 500);
        assert!(response.body.is_empty());
        assert_eq!(
            *calls.lock().unwrap(),
            [if custom { "custom" } else { "map" }]
        );
        assert_eq!(*fixture.requests.lock().unwrap(), ["/jwks"]);
        assert!(
            ctx.database
                .get_user_by_email("google-normal@example.test")
                .await
                .unwrap()
                .is_none()
        );
    }
}