better-auth-api 1.0.0-alpha.5

Plugin implementations for better-auth
#![expect(
    clippy::unwrap_used,
    clippy::indexing_slicing,
    reason = "Signed local fixtures fail immediately when setup or captured output differs."
)]

use super::google_test_support::GoogleFixture;
use super::profile_test_support::json as public_response;
use super::*;
use better_auth_core::{AuthError, SchemaValue};
use serde_json::{Value, json};
use std::sync::{
    Mutex,
    atomic::{AtomicBool, Ordering},
};

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

fn options(server: &GoogleFixture) -> CognitoOptions {
    let mut options = CognitoOptions::new(
        "ordinary.auth.us-east-1.amazoncognito.com",
        "us-east-1",
        "us-east-1_Ordinary",
    );
    options.jwks_url = Some(format!("{}/jwks", server.url));
    options
        .additional_client_ids
        .push("ordinary-secondary-client".into());
    options
}

fn provider(server: &GoogleFixture) -> OAuthProvider {
    let mut provider =
        OAuthProvider::cognito("ordinary-client", "ordinary-client-secret", options(server))
            .unwrap();
    provider.token_url = format!("{}/token", server.url);
    provider.user_info_url = Some(format!("{}/userinfo", server.url));
    provider
}

struct Mapper {
    seen: Arc<Mutex<Vec<Value>>>,
    patch: Value,
    fail_first: AtomicBool,
}

#[async_trait]
impl OAuthProfileMapper for Mapper {
    async fn map_profile(&self, profile: &Value) -> AuthResult<OAuthProfile> {
        self.seen.lock().unwrap().push(profile.clone());
        if self.fail_first.swap(false, Ordering::SeqCst) {
            return Err(AuthError::internal("ordinary mapper failure"));
        }
        Ok(OAuthProfile {
            name: self
                .patch
                .get("name")
                .cloned()
                .map(serde_json::from_value)
                .transpose()?,
            email: self
                .patch
                .get("email")
                .cloned()
                .map(|value| SchemaValue::from_json(Some(value)))
                .transpose()?,
            image: self
                .patch
                .get("image")
                .cloned()
                .map(serde_json::from_value)
                .transpose()?,
            email_verified: self
                .patch
                .get("emailVerified")
                .cloned()
                .map(|value| SchemaValue::from_json(Some(value)))
                .transpose()?,
            ..Default::default()
        })
    }
}

#[tokio::test]
async fn signed_claims_match_pinned_profiles_on_code_direct_and_account_reads() {
    let fixture = fixture();
    for sample in fixture["profileCases"]
        .as_array()
        .unwrap()
        .iter()
        .filter(|sample| sample["source"] == "id-token")
    {
        let mut claims = sample["profile"].clone();
        let now = chrono::Utc::now().timestamp();
        claims["iat"] = json!(now);
        claims["exp"] = json!(now + 3600);
        let server = GoogleFixture::start(claims).await;
        let mut expected = sample["result"].clone();
        expected["data"]["iat"] = json!(now);
        expected["data"]["exp"] = json!(now + 3600);
        let seen = Arc::new(Mutex::new(Vec::new()));
        let mut config = provider(&server);
        config.map_profile_to_user = Some(Arc::new(Mapper {
            seen: seen.clone(),
            patch: sample["mapperPatch"].clone(),
            fail_first: AtomicBool::new(false),
        }));
        let resolved = resolved::ResolvedProvider {
            config,
            generic: None,
        };
        for entry in ["code", "direct", "account"] {
            let request = OAuthUserInfoRequest {
                id_token: (Some(server.token.clone())).into(),
                ..Default::default()
            };
            let response = if entry == "direct" {
                let token = types::OAuthIdTokenRequest {
                    token: server.token.clone(),
                    nonce: None,
                    access_token: None,
                    refresh_token: None,
                    user: None,
                };
                let verified = id_token::verify(&resolved, &token, None).await.unwrap();
                social_profile::fetch_user_info_with_claims(&resolved, request, None, verified)
                    .await
                    .unwrap()
            } else if entry == "code" {
                social_profile::fetch_user_info_for_code(&resolved, request, None)
                    .await
                    .unwrap()
            } else {
                social_profile::fetch_user_info_from_provider(&resolved, request, None)
                    .await
                    .unwrap()
            };
            assert_eq!(
                public_response(response),
                expected,
                "{} {entry}",
                sample["name"]
            );
        }
        assert_eq!(*seen.lock().unwrap(), vec![expected["data"].clone(); 3]);
        assert_eq!(
            *server.requests.lock().unwrap(),
            ["/jwks", "/jwks", "/jwks"]
        );
    }
}

#[tokio::test]
async fn signed_mapper_failure_uses_the_separate_http_mapper_stage() {
    let fixture = fixture();
    let sample = &fixture["profileCases"][0];
    let mut claims = sample["profile"].clone();
    claims["iss"] = fixture["metadata"]["issuer"].clone();
    claims["aud"] = fixture["metadata"]["clientId"].clone();
    let server = GoogleFixture::start_with_userinfo(claims, Some(sample["profile"].clone())).await;
    let seen = Arc::new(Mutex::new(Vec::new()));
    let mut config = provider(&server);
    config.map_profile_to_user = Some(Arc::new(Mapper {
        seen: seen.clone(),
        patch: json!({}),
        fail_first: AtomicBool::new(true),
    }));
    let profile_request = OAuthUserInfoRequest {
        id_token: (Some(server.token.clone())).into(),
        access_token: (Some("ordinary-access".into())).into(),
        ..Default::default()
    };
    let response = social_profile::fetch_user_info_for_code(
        &resolved::ResolvedProvider {
            config,
            generic: None,
        },
        profile_request,
        None,
    )
    .await
    .unwrap();
    assert_eq!(public_response(response), sample["result"]);
    assert_eq!(seen.lock().unwrap().len(), 2);
    assert_eq!(seen.lock().unwrap()[1], sample["profile"]);
    assert_eq!(*server.requests.lock().unwrap(), ["/jwks", "/userinfo"]);
}

#[tokio::test]
async fn signed_code_and_direct_success_persist_normal_users_in_sqlite() {
    for direct in [false, true] {
        let mut claims = fixture()["profileCases"][0]["profile"].clone();
        claims["iss"] = fixture()["metadata"]["issuer"].clone();
        claims["aud"] = json!("ordinary-secondary-client");
        claims["iat"] = json!(chrono::Utc::now().timestamp() - 1800);
        let server = GoogleFixture::start(claims).await;
        let seen = Arc::new(Mutex::new(Vec::new()));
        let mut config = provider(&server);
        config.map_profile_to_user = Some(Arc::new(Mapper {
            seen: seen.clone(),
            patch: json!({"name":"Mapped Cognito Owner"}),
            fail_first: AtomicBool::new(false),
        }));
        let plugin = OAuthPlugin::new().add_provider("cognito", config);
        let mut config = crate::plugins::test_helpers::create_test_config();
        config.account.skip_state_cookie_check = true;
        let ctx = crate::plugins::test_helpers::create_test_context_with_config(config).await;
        let mut start = AuthRequest::new(HttpMethod::Post, "/sign-in/social");
        start.body = Some(serde_json::to_vec(&if direct { json!({"provider":"cognito","idToken":{"token":server.token}}) } else { json!({"provider":"cognito","callbackURL":"http://localhost:3000/welcome","disableRedirect":true}) }).unwrap());
        let response = plugin.on_request(&start, &ctx).await.unwrap().unwrap();
        assert_eq!(response.status, 200);
        if !direct {
            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(|(key, _)| key == "state")
                .unwrap()
                .1
                .into_owned();
            let mut callback = AuthRequest::new(HttpMethod::Get, "/callback/cognito");
            callback.query = Some(json!({"code":"ordinary-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("cognito-owner@example.test")
            .await
            .unwrap()
            .unwrap();
        let user_json = serde_json::to_value(&user).unwrap();
        assert_eq!(user_json["name"], "Mapped Cognito Owner");
        assert_eq!(user_json["emailVerified"], true);
        let accounts = ctx
            .database
            .get_user_accounts(&user.id.display_string().unwrap())
            .await
            .unwrap();
        assert_eq!(accounts.len(), 1);
        assert_eq!(accounts[0].provider_id.as_str(), Some("cognito"));
        assert_eq!(
            accounts[0].account_id.as_str(),
            Some("ordinary-cognito-owner")
        );
        assert_eq!(seen.lock().unwrap().len(), 1);
        assert_eq!(
            *server.requests.lock().unwrap(),
            if direct {
                vec!["/jwks"]
            } else {
                vec!["/token", "/jwks"]
            }
        );
    }
}

struct ApplicationVerifier(CognitoOptions);

#[async_trait]
impl OAuthIdTokenVerifier for ApplicationVerifier {
    async fn verify_id_token(
        &self,
        token: &str,
        nonce: Option<&str>,
        _: Option<better_auth_core::NativeRequest<'_>>,
    ) -> Result<bool, String> {
        self.0
            .verify("ordinary-client", token, nonce)
            .await
            .map(|_| true)
            .map_err(|error| error.to_string())
    }
}

#[tokio::test]
async fn application_verified_signed_claims_are_not_verified_a_second_time() {
    let mut claims = fixture()["profileCases"][0]["profile"].clone();
    claims["iss"] = fixture()["metadata"]["issuer"].clone();
    claims["aud"] = json!("ordinary-client");
    claims["nonce"] = json!("ordinary-nonce");
    let server = GoogleFixture::start(claims).await;
    let mut config = provider(&server);
    config.verify_id_token = Some(Arc::new(ApplicationVerifier(options(&server))));
    let resolved = resolved::ResolvedProvider {
        config,
        generic: None,
    };
    let token = types::OAuthIdTokenRequest {
        token: server.token.clone(),
        nonce: Some("ordinary-nonce".into()),
        access_token: None,
        refresh_token: None,
        user: None,
    };
    let verified = id_token::verify(&resolved, &token, None).await.unwrap();
    let profile_request = OAuthUserInfoRequest {
        id_token: (Some(server.token.clone())).into(),
        ..Default::default()
    };
    let response = social_profile::fetch_user_info_with_claims(
        &resolved,
        profile_request,
        Some("ordinary-nonce"),
        verified,
    )
    .await
    .unwrap();
    assert_eq!(
        public_response(response)["user"],
        fixture()["profileCases"][0]["result"]["user"]
    );
    assert_eq!(*server.requests.lock().unwrap(), ["/jwks"]);
}