better-auth-api 1.0.0-alpha.5

Plugin implementations for better-auth
#![allow(
    clippy::expect_used,
    reason = "Fixture setup and response assertions require successful fallible construction."
)]
use std::{
    collections::BTreeMap,
    sync::{Arc, Mutex},
};

use async_trait::async_trait;
use axum::{
    Router,
    http::{HeaderMap, StatusCode},
    routing::get,
};
use better_auth_core::{
    AuthError, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthUser, FieldMap, HttpMethod,
};
use serde::Deserialize;
use serde_json::{Value, json};

use super::super::{
    GenericOAuthConfig, GenericOAuthProfileContext, GenericOAuthUserInfoHandler,
    OAuthAccountSubject, OAuthCodeExchange, OAuthPlugin, OAuthProfile, OAuthProfileMapper,
    OAuthTokenHandler, OAuthTokenSet, OAuthUserInfoRequest,
};
use super::{GenericProfileError, ResolvedGenericOAuth, fetch_profile, resolve_account_subject};
use crate::plugins::test_helpers;

type TestResult<T = ()> = Result<T, Box<dyn std::error::Error>>;
type Records<T> = Arc<Mutex<Vec<T>>>;

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Fixture {
    version: String,
    profile: Value,
    error_body: Value,
    error_headers: BTreeMap<String, String>,
    cases: Vec<Case>,
    preset_absence: Value,
}

#[derive(Deserialize)]
struct Case {
    name: String,
    source: String,
    result: String,
    helper: Value,
    callback: Value,
}

struct Application {
    result: String,
    profile: Value,
    error_body: Value,
    error_headers: BTreeMap<String, String>,
    events: Records<String>,
}

impl Application {
    fn event(&self, event: &str) -> AuthResult<()> {
        self.events
            .lock()
            .map_err(|error| AuthError::internal(error.to_string()))?
            .push(event.to_owned());
        Ok(())
    }

    fn error(&self) -> AuthResult<AuthError> {
        let mut response = AuthResponse::json(429, &self.error_body)?;
        for (name, value) in &self.error_headers {
            response.headers.append(name.clone(), value.clone());
        }
        Ok(AuthError::from(response))
    }
}

#[async_trait]
impl GenericOAuthUserInfoHandler for Application {
    async fn get_user_info(&self, _: &OAuthUserInfoRequest) -> AuthResult<Option<Value>> {
        self.event("get")?;
        if self.result == "error" {
            return Err(self.error()?);
        }
        Ok((self.result != "null").then(|| self.profile.clone()))
    }
}

#[async_trait]
impl OAuthProfileMapper for Application {
    async fn map_profile(&self, profile: &Value) -> AuthResult<OAuthProfile> {
        self.event("map")?;
        assert_eq!(profile.get("id"), self.profile.get("id"));
        assert_eq!(profile.get("email"), self.profile.get("email"));
        if self.result == "mapper-error" {
            return Err(self.error()?);
        }
        Ok(OAuthProfile {
            name: Some(Some("Mapped Reader".to_owned()).into()),
            email: Some(Some("mapped@example.test".to_owned()).into()),
            email_verified: Some(Some(true).into()),
            ..Default::default()
        })
    }
}

#[async_trait]
impl OAuthAccountSubject for Application {
    async fn resolve_subject(
        &self,
        _: &OAuthUserInfoRequest,
        profile: &Value,
    ) -> AuthResult<String> {
        self.event("subject")?;
        assert_eq!(profile.get("email"), self.profile.get("email"));
        profile
            .get("id")
            .and_then(Value::as_str)
            .map(str::to_owned)
            .ok_or_else(|| AuthError::internal("Missing ordinary profile subject"))
    }
}

#[async_trait]
impl OAuthTokenHandler for Application {
    async fn get_token(&self, _: OAuthCodeExchange<'_>) -> AuthResult<OAuthTokenSet> {
        self.event("token")?;
        Ok(OAuthTokenSet {
            access_token: (Some("ordinary-profile-access".into())).into(),
            ..Default::default()
        })
    }
}

struct Server {
    url: String,
    task: tokio::task::JoinHandle<std::io::Result<()>>,
}

impl Drop for Server {
    fn drop(&mut self) {
        self.task.abort();
    }
}

impl Server {
    async fn start(
        case: &Case,
        fixture: &Fixture,
        events: Records<String>,
        requests: Records<Value>,
    ) -> TestResult<Self> {
        let result = case.result.clone();
        let profile = fixture.profile.clone();
        let app = Router::new().route("/userinfo", get(move |headers: HeaderMap| {
            let (result, profile, events, requests) = (result.clone(), profile.clone(), events.clone(), requests.clone());
            async move {
                events.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?.push("http".into());
                requests.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?.push(json!({
                    "path": "/userinfo", "method": "GET",
                    "authorization": headers.get("authorization").and_then(|value| value.to_str().ok()),
                }));
                let (status, body) = match result.as_str() {
                    "empty" => (StatusCode::NO_CONTENT, String::new()),
                    "error" => (StatusCode::SERVICE_UNAVAILABLE, json!({"message":"Profile service unavailable"}).to_string()),
                    "null" => (StatusCode::OK, "null".into()),
                    _ => (StatusCode::OK, profile.to_string()),
                };
                Ok::<_, StatusCode>((status, [("content-type", "application/json")], body))
            }
        }));
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
        let url = format!("http://{}/userinfo", listener.local_addr()?);
        let task = tokio::spawn(async move { axum::serve(listener, app).await });
        Ok(Self { url, task })
    }
}

fn config(case: &Case, application: &Arc<Application>, server: &Server) -> GenericOAuthConfig {
    GenericOAuthConfig {
        client_id: "ordinary-client".into(),
        authorization_url: Some("https://provider.example.test/authorize".into()),
        user_info_url: (case.source == "http").then(|| server.url.clone()),
        get_user_info: if case.source == "custom" {
            Some(application.clone())
        } else {
            None
        },
        map_profile_to_user: Some(application.clone()),
        account_subject: Some(application.clone()),
        get_token: Some(application.clone()),
        ..Default::default()
    }
}

fn drain<T>(records: &Records<T>) -> TestResult<Vec<T>> {
    Ok(std::mem::take(
        &mut *records.lock().map_err(|error| error.to_string())?,
    ))
}

fn response_result(response: AuthResponse) -> TestResult<Value> {
    let response = response.into_http_response()?;
    let body = if response.body.is_empty() {
        Value::Null
    } else {
        serde_json::from_slice(&response.body.bytes()?)?
    };
    let headers: BTreeMap<_, _> = ["retry-after", "x-profile-error"]
        .into_iter()
        .filter_map(|name| response.headers.get(name).map(|value| (name, value)))
        .collect();
    Ok(json!({
        "status": response.status, "location": response.headers.get("location"), "body": body,
        "headers": headers,
    }))
}

#[tokio::test]
async fn generic_profile_results_match_pinned_helper_and_callback_contracts() -> TestResult {
    let fixture: Fixture = serde_json::from_str(&std::fs::read_to_string(concat!(
        env!("CARGO_MANIFEST_DIR"),
        "/../../tests/fixtures/generic-profile-results-1.7.6.json",
    ))?)?;
    assert_eq!(fixture.version, "1.7.6");
    for case in &fixture.cases {
        let events = Arc::new(Mutex::new(Vec::new()));
        let requests = Arc::new(Mutex::new(Vec::new()));
        let server = Server::start(case, &fixture, events.clone(), requests.clone()).await?;
        let application = Arc::new(Application {
            result: case.result.clone(),
            profile: fixture.profile.clone(),
            error_body: fixture.error_body.clone(),
            error_headers: fixture.error_headers.clone(),
            events: events.clone(),
        });
        let provider = ResolvedGenericOAuth {
            config: config(case, &application, &server),
            issuer: None,
            is_oidc: false,
            verifier: None,
        };
        let tokens = OAuthUserInfoRequest {
            access_token: (Some("ordinary-profile-access".into())).into(),
            ..Default::default()
        };
        let result: Result<Value, GenericProfileError> = async {
            let response = fetch_profile(&provider, &tokens, None).await?;
            if !response.is_truthy() {
                return Ok(json!({"kind":"absent"}));
            }
            let data = response.model_property("data")?;
            let subject =
                resolve_account_subject(&provider, &tokens.fields().into(), &data).await?;
            Ok(Value::Object(
                FieldMap::from([
                    ("kind".into(), "profile".into()),
                    ("user".into(), response.model_property("user")?),
                    ("data".into(), data),
                    ("subject".into(), subject),
                ])
                .json()?,
            ))
        }
        .await;
        let outcome = match result {
            Ok(outcome) => outcome,
            Err(error) => {
                json!({"kind":"error","response":response_result(error.into_auth_error().to_auth_response().expect("fixture response succeeds"))?})
            }
        };
        assert_eq!(
            json!({"outcome":outcome,"events":drain(&events)?,"requests":drain(&requests)?}),
            case.helper,
            "{} helper",
            case.name
        );

        let ctx = test_helpers::create_test_context().await;
        let plugin =
            OAuthPlugin::new().add_generic_provider("generic", config(case, &application, &server));
        let mut start = AuthRequest::new(HttpMethod::Post, "/sign-in/social").with_url(
            url::Url::parse("http://localhost:3000/api/auth/sign-in/social")?,
        );
        let _ = start
            .headers
            .insert("content-type".into(), "application/json".into());
        let _ = start
            .headers
            .insert("origin".into(), "http://localhost:3000".into());
        start.body = Some(serde_json::to_vec(&json!({
            "provider":"generic", "callbackURL":"http://localhost:3000/welcome",
            "errorCallbackURL":"http://localhost:3000/error", "disableRedirect":true,
        }))?);
        let mut started = plugin
            .on_request(&start, &ctx)
            .await?
            .ok_or("Missing sign-in response")?;
        ctx.session_manager()
            .finish_response(&start, &mut started)?;
        assert_eq!(started.status, 200);
        let body: Value = serde_json::from_slice(&started.body.bytes()?)?;
        let url = url::Url::parse(
            body.get("url")
                .and_then(Value::as_str)
                .ok_or("Missing authorization URL")?,
        )?;
        let state = url
            .query_pairs()
            .find(|(key, _)| key == "state")
            .ok_or("Missing state")?
            .1
            .into_owned();
        let cookie = started
            .headers
            .get_all("set-cookie")
            .filter_map(|value| value.split(';').next())
            .collect::<Vec<_>>()
            .join("; ");
        let mut callback_url = url::Url::parse("http://localhost:3000/api/auth/callback/generic")?;
        callback_url
            .query_pairs_mut()
            .append_pair("state", &state)
            .append_pair("code", "ordinary-code");
        let mut callback =
            AuthRequest::new(HttpMethod::Get, "/callback/generic").with_url(callback_url);
        callback.query = Some(json!({"state":state,"code":"ordinary-code"}));
        let _ = callback.headers.insert("cookie".into(), cookie);
        let response = match plugin.on_request(&callback, &ctx).await {
            Ok(Some(mut response)) => {
                ctx.session_manager()
                    .finish_response(&callback, &mut response)?;
                response
            }
            Ok(None) => return Err("Missing callback response".into()),
            Err(error) => error.to_auth_response().expect("fixture response succeeds"),
        };
        let mut users = Vec::new();
        let mut accounts = Vec::new();
        let mut sessions = 0;
        for email in ["mapped@example.test", "reader@example.test"] {
            if let Some(user) = ctx.database.get_user_by_email(email).await? {
                let id = user.id().display_string()?;
                let fields = serde_json::to_value(&user)?;
                users.push(json!({"email":fields.get("email"),"name":fields.get("name"),"emailVerified":fields.get("emailVerified")}));
                for account in ctx.database.get_user_accounts(&id).await? {
                    accounts.push(
                        json!({"providerId":account.provider_id,"accountId":account.account_id}),
                    );
                }
                sessions += ctx.database.get_user_sessions(&id).await?.len();
            }
        }
        assert_eq!(
            json!({
                "response":response_result(response)?, "events":drain(&events)?, "requests":drain(&requests)?,
                "storage":{"user":users,"account":accounts,"sessions":sessions},
            }),
            case.callback,
            "{} callback",
            case.name
        );
    }
    let entra = GenericOAuthConfig::microsoft_entra_id(
        "client",
        "secret",
        "11111111-1111-1111-1111-111111111111",
    )?;
    let handler = entra
        .get_user_info
        .as_ref()
        .ok_or("Missing Entra handler")?;
    let tokens = OAuthUserInfoRequest::default();
    let result = handler
        .get_user_info_with_context(&tokens, GenericOAuthProfileContext::new(&entra, None, None))
        .await?;
    let mut preset_absence = vec![json!({"name":"entra-no-token","result":result})];
    for result in ["null", "empty", "error"] {
        let case = Case {
            name: format!("line-{result}"),
            source: "http".into(),
            result: result.into(),
            helper: Value::Null,
            callback: Value::Null,
        };
        let server = Server::start(&case, &fixture, Default::default(), Default::default()).await?;
        let mut line = GenericOAuthConfig::line("client", "secret");
        line.user_info_url = Some(server.url.clone());
        let handler = line.get_user_info.as_ref().ok_or("Missing LINE handler")?;
        let tokens = OAuthUserInfoRequest {
            access_token: (Some("ordinary-profile-access".into())).into(),
            ..Default::default()
        };
        let result = handler
            .get_user_info_with_context(&tokens, GenericOAuthProfileContext::new(&line, None, None))
            .await?;
        preset_absence.push(json!({"name":case.name,"result":result}));
    }
    assert_eq!(json!(preset_absence), fixture.preset_absence);
    Ok(())
}