#![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"]
);
}