better-auth-api 1.0.0-alpha.5

Plugin implementations for better-auth
#![expect(
    clippy::unwrap_used,
    clippy::indexing_slicing,
    reason = "Ordinary local contracts fail immediately when fixture setup or captured results change."
)]

use super::*;
use base64::Engine;
use better_auth_core::{AuthError, SchemaValue};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::sync::{Mutex, atomic::Ordering};

#[path = "paybin_test_support.rs"]
mod support;
use support::{Mapper, Server, fixture, resolved};
#[path = "paybin_sqlite_tests.rs"]
mod sqlite;

fn configured(options: &Value) -> OAuthProvider {
    let metadata = &fixture()["metadata"];
    let client = options["clientId"]
        .as_str()
        .unwrap_or(metadata["clientId"].as_str().unwrap());
    let secret = options["clientSecret"]
        .as_str()
        .unwrap_or(metadata["clientSecret"].as_str().unwrap());
    let mut config = options["issuer"].as_str().map_or_else(
        || OAuthProvider::paybin(client, secret),
        |issuer| OAuthProvider::paybin_with_issuer(client, secret, issuer),
    );
    config.scopes = options
        .get("scope")
        .cloned()
        .map(serde_json::from_value)
        .transpose()
        .unwrap();
    config.disable_default_scope = options["disableDefaultScope"].as_bool().unwrap_or(false);
    config.prompt = options["prompt"].as_str().map(str::to_owned);
    config.redirect_uri = options["redirectURI"].as_str().map(str::to_owned);
    if let Some(endpoint) = options["authorizationEndpoint"].as_str() {
        config.auth_url = endpoint.into();
    }
    config
}

fn authorization(options: &Value, input: &Value) -> AuthResult<String> {
    let challenge = input["codeVerifier"]
        .as_str()
        .filter(|value| !value.is_empty())
        .map(|value| {
            base64::engine::general_purpose::URL_SAFE_NO_PAD
                .encode(Sha256::digest(value.as_bytes()))
        })
        .unwrap_or_default();
    let scopes: Option<Vec<String>> = input
        .get("scopes")
        .cloned()
        .map(serde_json::from_value)
        .transpose()
        .unwrap();
    let additional_params = input
        .get("additionalParams")
        .cloned()
        .map(serde_json::from_value)
        .transpose()
        .unwrap();
    authorization::build_authorization_url(
        &resolved(configured(options)),
        authorization::AuthorizationRequest {
            callback_url: input["redirectURI"].as_str().unwrap(),
            scopes: scopes.as_deref(),
            state: input["state"].as_str().unwrap(),
            code_challenge: &challenge,
            login_hint: input["loginHint"].as_str(),
            nonce: input["idTokenNonce"].as_str(),
            additional_params: additional_params.as_ref(),
        },
    )
}

#[test]
fn authorization_and_configuration_match_pinned_paybin() {
    let fixture = fixture();
    for sample in fixture["scopeCases"].as_array().unwrap() {
        assert_eq!(
            authorization(&sample["options"], &sample["input"]).unwrap(),
            sample["url"].as_str().unwrap(),
            "{}",
            sample["name"]
        );
    }
    for sample in fixture["configurationErrors"].as_array().unwrap() {
        let error = authorization(&sample["options"], &sample["input"]).unwrap_err();
        assert!(
            matches!(error, AuthError::Internal(message) if message == sample["error"]["message"].as_str().unwrap())
        );
    }
}

fn token_json(token: OAuthTokenSet) -> Value {
    let mut result = token.fields().json().unwrap();
    if result.contains_key("idToken") {
        let _ = result.insert("idToken".into(), "<signed ID token>".into());
    }
    if let Some(raw) = result.get_mut("raw") {
        raw["id_token"] = "<signed ID token>".into();
    }
    Value::Object(result)
}

#[tokio::test]
async fn paybin_grants_use_pinned_post_fields_and_normal_http_errors() {
    let fixture = fixture();
    let server = Server::start(fixture["profileCases"][0]["profile"].clone()).await;
    let resolved = resolved(server.provider());
    for sample in fixture["grants"].as_array().unwrap() {
        server.requests.lock().unwrap().clear();
        server
            .unavailable
            .store(sample.get("error").is_some(), Ordering::SeqCst);
        let result = if sample["name"].as_str().unwrap().starts_with("code") {
            provider_tokens::validate_authorization_code_via_provider(
                &resolved,
                "ordinary-code",
                fixture["metadata"]["redirectURI"].as_str().unwrap(),
                fixture["metadata"]["verifier"].as_str(),
                Some("ordinary-unused-device"),
            )
            .await
        } else {
            provider_tokens::refresh_tokens_via_provider(
                &resolved,
                &"ordinary-refresh".into(),
                &AuthRequest::new(HttpMethod::Post, "/refresh-token"),
            )
            .await
        };
        assert_eq!(json!(*server.requests.lock().unwrap()), sample["requests"]);
        if sample.get("error").is_some() {
            assert!(matches!(result, Err(AuthError::Internal(_))));
        } else {
            assert_eq!(token_json(result.unwrap()), sample["response"]);
        }
    }
}

#[tokio::test]
async fn verified_paybin_profiles_and_mapper_data_match_pinned_results() {
    for sample in fixture()["profileCases"].as_array().unwrap() {
        let server = Server::start(sample["profile"].clone()).await;
        let seen = Arc::default();
        let mut config = server.provider();
        config.map_profile_to_user = Some(Arc::new(Mapper {
            patch: sample["patch"].clone(),
            seen: Arc::clone(&seen),
            error: false,
        }));
        let resolved = resolved(config);
        for code_flow in [true, false] {
            let profile_request = server.request();
            let subject_tokens = OAuthTokenSet::from_fields(profile_request.fields());
            let response = if code_flow {
                social_profile::fetch_user_info_for_code(&resolved, profile_request, None).await
            } else {
                social_profile::fetch_user_info_from_provider(&resolved, profile_request, None)
                    .await
            }
            .unwrap();
            assert_eq!(
                profile_test_support::subject(&resolved, &subject_tokens, &response)
                    .await
                    .json()
                    .unwrap(),
                Some(sample["profile"]["sub"].clone())
            );
            let result = profile_test_support::json(response);
            assert_eq!(
                result["user"], sample["result"]["user"],
                "{}",
                sample["name"]
            );
            assert_eq!(result["data"], server.claims);
        }
        assert_eq!(
            *seen.lock().unwrap(),
            [server.claims.clone(), server.claims.clone()]
        );
        assert_eq!(
            *server.paths.lock().unwrap(),
            [
                "/.well-known/openid-configuration",
                "/keys/active",
                "/.well-known/openid-configuration",
                "/keys/active"
            ]
        );
    }
}

struct CustomInfo {
    mode: String,
    calls: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl OAuthUserInfoHandler for CustomInfo {
    async fn get_user_info_native(
        &self,
        _: OAuthUserInfoRequest,
    ) -> AuthResult<better_auth_core::FieldValue> {
        self.calls.lock().unwrap().push("custom".into());
        if self.mode == "custom error" {
            return Err(AuthError::internal("Ordinary Paybin custom error"));
        }
        if self.mode == "custom null" {
            return Ok(better_auth_core::FieldValue::Null);
        }
        Ok(json!({
            "user": {
                "name": "Custom Owner",
                "email": "custom@example.com",
                "emailVerified": false
            },
            "data": {"ordinary": "custom profile"}
        })
        .into())
    }
}

#[tokio::test]
async fn paybin_missing_profile_custom_handlers_and_mapper_keep_original_results() {
    let fixture = fixture();
    let server = Server::start(fixture["profileCases"][0]["profile"].clone()).await;
    for sample in fixture["specialCases"].as_array().unwrap() {
        let mode = sample["mode"].as_str().unwrap();
        let calls = Arc::default();
        let seen = Arc::default();
        let mut provider = server.provider();
        provider.map_profile_to_user = Some(Arc::new(Mapper {
            patch: json!({}),
            seen: Arc::clone(&seen),
            error: true,
        }));
        if mode.starts_with("custom") {
            provider.get_user_info = Some(Arc::new(CustomInfo {
                mode: mode.into(),
                calls: Arc::clone(&calls),
            }));
        }
        let profile_request = if mode == "mapper error" {
            server.request()
        } else {
            Default::default()
        };
        let result =
            social_profile::fetch_user_info_for_code(&resolved(provider), profile_request, None)
                .await;
        let mut events = calls.lock().unwrap().clone();
        events.extend(seen.lock().unwrap().iter().map(|_| "mapper".to_owned()));
        match result {
            Ok(result) => {
                events.push("returned".into());
                assert_eq!(profile_test_support::json(result), sample["result"]);
            }
            Err(error) => {
                events.push("rejected".into());
                assert!(
                    matches!(error, AuthError::Internal(message) if message == sample["error"]["message"].as_str().unwrap())
                );
            }
        }
        assert_eq!(json!(events), sample["events"]);
    }
}

struct ApplicationVerifier {
    issuer: String,
}
#[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> {
        providers::paybin::verify(&self.issuer, "ordinary-client", token, nonce)
            .await
            .map(|_| true)
            .map_err(|error| error.to_string())
    }
}

#[tokio::test]
async fn application_verified_paybin_profile_uses_the_completed_signature_check() {
    let server = Server::start(fixture()["profileCases"][0]["profile"].clone()).await;
    let mut provider = server.provider();
    provider.verify_id_token = Some(Arc::new(ApplicationVerifier {
        issuer: server.issuer.clone(),
    }));
    let provider = resolved(provider);
    let verified = id_token::verify(
        &provider,
        &types::OAuthIdTokenRequest {
            token: server.token.clone(),
            nonce: None,
            access_token: None,
            refresh_token: None,
            user: None,
        },
        None,
    )
    .await
    .unwrap();
    let profile_request = server.request();
    let profile =
        social_profile::fetch_user_info_with_claims(&provider, profile_request, None, verified)
            .await
            .unwrap();
    assert_eq!(profile_test_support::data(&profile), server.claims);
    assert_eq!(
        *server.paths.lock().unwrap(),
        ["/.well-known/openid-configuration", "/keys/active"]
    );
}

struct CustomRefresh(Arc<Mutex<Vec<String>>>);
#[async_trait]
impl OAuthRefreshTokenHandler for CustomRefresh {
    async fn refresh_access_token(
        &self,
        token: &better_auth_core::FieldValue,
        _: Option<better_auth_core::NativeRequest<'_>>,
    ) -> Result<OAuthTokenSet, String> {
        self.0.lock().unwrap().push(token.as_str().unwrap().into());
        Ok(OAuthTokenSet {
            access_token: (Some("custom-access".into())).into(),
            refresh_token: (Some("custom-refresh".into())).into(),
            scopes: (vec!["custom".into()]).into(),
            ..Default::default()
        })
    }
}

#[tokio::test]
async fn paybin_custom_refresh_retains_precedence() {
    let calls = Arc::default();
    let mut provider = OAuthProvider::paybin("ordinary-client", "ordinary-client-secret");
    provider.refresh_access_token = Some(Arc::new(CustomRefresh(Arc::clone(&calls))));
    let token = provider_tokens::refresh_tokens_via_provider(
        &resolved(provider),
        &"ordinary-refresh".into(),
        &AuthRequest::new(HttpMethod::Post, "/refresh-token"),
    )
    .await
    .unwrap();
    assert_eq!(token_json(token), fixture()["customRefresh"]["response"]);
    assert_eq!(
        json!(*calls.lock().unwrap()),
        fixture()["customRefresh"]["events"]
    );
}