use async_trait::async_trait;
use better_auth_core::AuthResult;
use indexmap::IndexMap;
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use serde_json::{Value, json};
use std::{error::Error, sync::Arc};
use tokio::sync::Mutex;
use crate::plugins::oauth::{
OAuthProfile, OAuthProfileMapper, OAuthProvider, OAuthTokenSet, OAuthUserInfo,
OAuthUserInfoHandler, OAuthUserInfoRequest, OAuthUserInfoResponse, TwitchOptions,
authorization::{AuthorizationRequest, build_authorization_url},
profile_test_support as profile_test,
resolved::ResolvedProvider,
social_profile::fetch_user_info_from_provider,
};
type TestResult<T> = Result<T, Box<dyn Error>>;
fn fixture() -> TestResult<Value> {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../tests/fixtures/social-twitch-1.7.6.json"
);
Ok(serde_json::from_str(&std::fs::read_to_string(path)?)?)
}
fn config(fixture: &Value, options: &Value) -> TestResult<OAuthProvider> {
let mut provider = OAuthProvider::twitch(
fixture["clientId"]
.as_str()
.ok_or("missing Twitch client ID")?,
fixture["clientSecret"]
.as_str()
.ok_or("missing Twitch client secret")?,
TwitchOptions {
claims: options
.get("claims")
.map(|value| serde_json::from_value(value.clone()))
.transpose()?,
},
);
provider.client_key = Some(
fixture["clientKey"]
.as_str()
.ok_or("missing Twitch client key")?
.into(),
);
provider.scopes = options
.get("scope")
.map(|value| serde_json::from_value(value.clone()))
.transpose()?;
provider.disable_default_scope = options["disableDefaultScope"].as_bool().unwrap_or(false);
provider.prompt = options["prompt"].as_str().map(str::to_owned);
if let Some(url) = options["authorizationEndpoint"].as_str() {
provider.auth_url = url.into();
}
provider.redirect_uri = options["redirectURI"].as_str().map(str::to_owned);
Ok(provider)
}
fn resolved(config: OAuthProvider) -> ResolvedProvider {
ResolvedProvider {
config: config.resolve(),
generic: None,
}
}
fn tokens(fixture: &Value, profile: &Value) -> TestResult<OAuthUserInfoRequest> {
let secret = fixture["clientSecret"]
.as_str()
.ok_or("missing Twitch client secret")?;
Ok(OAuthUserInfoRequest {
access_token: (Some("ordinary-access".into())).into(),
id_token: (Some(jsonwebtoken::encode(
&Header::new(Algorithm::HS256),
profile,
&EncodingKey::from_secret(secret.as_bytes()),
)?))
.into(),
..Default::default()
})
}
#[test]
fn twitch_authorization_matches_the_pinned_ordinary_contract() -> TestResult<()> {
let fixture = fixture()?;
for case in fixture["authorization"]
.as_array()
.ok_or("missing Twitch authorization cases")?
{
let provider = resolved(config(&fixture, &case["options"])?);
let scopes: Option<Vec<String>> = case
.get("requestScopes")
.map(|value| serde_json::from_value(value.clone()))
.transpose()?;
let additional: Option<IndexMap<String, String>> = case
.get("additionalParams")
.map(|value| serde_json::from_value(value.clone()))
.transpose()?;
let actual = build_authorization_url(
&provider,
AuthorizationRequest {
callback_url: fixture["callbackURL"]
.as_str()
.ok_or("missing Twitch callback URL")?,
scopes: scopes.as_deref(),
state: "ordinary-state",
code_challenge: "ordinary-unused-challenge",
login_hint: Some("twitch@example.test"),
nonce: Some("ordinary-nonce"),
additional_params: additional.as_ref(),
},
)?;
assert_eq!(json!(actual), case["url"], "{}", case["name"]);
}
Ok(())
}
#[tokio::test]
async fn twitch_profiles_match_the_pinned_ordinary_contract() -> TestResult<()> {
let fixture = fixture()?;
let provider = resolved(config(&fixture, &json!({}))?);
for case in fixture["profiles"]
.as_array()
.ok_or("missing Twitch profiles")?
{
let profile_request = tokens(&fixture, &case["profile"])?;
let subject_tokens = OAuthTokenSet::from_fields(profile_request.fields());
let response = fetch_user_info_from_provider(&provider, profile_request, None).await?;
assert_eq!(
profile_test::subject(&provider, &subject_tokens, &response)
.await
.json()?,
Some(case["profile"]["sub"].clone())
);
assert_eq!(
profile_test::json(response),
case["result"],
"{}",
case["name"]
);
}
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(profile.clone());
Ok(OAuthProfile {
name: Some(Some("Mapped Twitch Reader".into()).into()),
image: Some(None),
email_verified: Some(Some(false).into()),
additional_fields: [("locale".into(), "en-GB".into())].into_iter().collect(),
..Default::default()
})
}
}
struct Custom(Arc<Mutex<usize>>);
#[async_trait]
impl OAuthUserInfoHandler for Custom {
async fn get_user_info(
&self,
_request: OAuthUserInfoRequest,
) -> AuthResult<Option<OAuthUserInfoResponse>> {
*self.0.lock().await += 1;
Ok(Some(OAuthUserInfoResponse {
user: OAuthUserInfo {
id: "ordinary-twitch-user".into(),
name: Some("Custom Twitch Reader".into()).into(),
email: Some("custom-twitch@example.test".into()).into(),
image: Some(None),
email_verified: Some(true).into(),
additional_fields: [("source".into(), "custom".into())].into_iter().collect(),
},
data: json!({"sub":"ordinary-twitch-user", "source":"custom"}),
}))
}
}
#[tokio::test]
async fn twitch_mapper_and_custom_handler_match_the_pinned_contract() -> TestResult<()> {
let fixture = fixture()?;
let inputs = Arc::new(Mutex::new(Vec::new()));
let mut config = config(&fixture, &json!({}))?;
config.map_profile_to_user = Some(Arc::new(Mapper(inputs.clone())));
let profile_request = tokens(&fixture, &fixture["profiles"][0]["profile"])?;
let provider = resolved(config.clone());
let subject_tokens = OAuthTokenSet::from_fields(profile_request.fields());
let response = fetch_user_info_from_provider(&provider, profile_request, None).await?;
assert_eq!(
profile_test::subject(&provider, &subject_tokens, &response)
.await
.json()?,
Some(fixture["profiles"][0]["profile"]["sub"].clone())
);
assert_eq!(json!(*inputs.lock().await), fixture["mapperInputs"]);
assert_eq!(profile_test::json(response), fixture["mappedResult"]);
let custom_inputs = Arc::new(Mutex::new(Vec::new()));
let calls = Arc::new(Mutex::new(0));
config.map_profile_to_user = Some(Arc::new(Mapper(custom_inputs.clone())));
config.get_user_info = Some(Arc::new(Custom(calls.clone())));
let profile_request = tokens(&fixture, &fixture["profiles"][0]["profile"])?;
let response = fetch_user_info_from_provider(&resolved(config), profile_request, None).await?;
assert_eq!(profile_test::json(response), fixture["customResult"]);
assert_eq!(json!(*calls.lock().await), fixture["customCalls"]);
assert_eq!(
json!(custom_inputs.lock().await.len()),
fixture["customMapperCalls"]
);
Ok(())
}
#[cfg(feature = "axum")]
#[tokio::test]
async fn twitch_grants_match_the_pinned_ordinary_contract() -> TestResult<()> {
use std::collections::BTreeMap;
let fixture = fixture()?;
let expected = fixture["requests"]
.as_array()
.ok_or("missing Twitch grant requests")?
.iter()
.map(|request| {
let body: BTreeMap<String, String> = url::form_urlencoded::parse(
request["body"]
.as_str()
.ok_or("missing Twitch form body")?
.as_bytes(),
)
.into_owned()
.collect();
Ok(json!({
"method": request["method"],
"contentType": request["headers"]["content-type"],
"accept": request["headers"]["accept"],
"authorization": request["headers"].get("authorization"),
"body": body,
}))
})
.collect::<TestResult<Vec<_>>>()?;
let (requests, tokens) = crate::plugins::oauth::social_token_wire_tests::grants(
config(&fixture, &json!({}))?,
fixture["callbackURL"]
.as_str()
.ok_or("missing Twitch callback URL")?,
fixture["codeVerifier"]
.as_str()
.ok_or("missing Twitch code verifier")?,
fixture["grantTokens"][0]["raw"].clone(),
)
.await?;
assert_eq!(requests, expected);
assert_eq!(tokens, fixture["grantTokens"]);
Ok(())
}