better-auth-api 1.0.0-alpha.5

Plugin implementations for better-auth
use super::provider_tokens::{
    refresh_tokens_via_provider, validate_authorization_code_via_provider,
};
use super::providers::{OAuthProvider, OAuthTokenSet};
use super::resolved::ResolvedProvider;
use axum::{
    Form, Json, Router,
    extract::State,
    http::{HeaderMap, Method, StatusCode},
    routing::post,
};
use better_auth_core::{AuthRequest, HttpMethod};
use serde_json::{Value, json};
use std::{collections::BTreeMap, error::Error, sync::mpsc};

type TestResult<T> = Result<T, Box<dyn Error>>;

async fn token(
    State((response, requests)): State<(Value, mpsc::Sender<Value>)>,
    method: Method,
    headers: HeaderMap,
    Form(body): Form<BTreeMap<String, String>>,
) -> Result<Json<Value>, StatusCode> {
    let header = |name| {
        headers
            .get(name)
            .map(|value| value.to_str())
            .transpose()
            .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
    };
    requests
        .send(json!({
            "method": method.as_str(),
            "contentType": header("content-type")?,
            "accept": header("accept")?,
            "authorization": header("authorization")?,
            "body": body,
        }))
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(response))
}

fn token_json(token: OAuthTokenSet) -> Value {
    Value::Object(token.fields().json().unwrap())
}

pub(super) async fn grants(
    mut config: OAuthProvider,
    callback: &str,
    verifier: &str,
    response: Value,
) -> TestResult<(Vec<Value>, Value)> {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
    config.token_url = format!("http://{}/token", listener.local_addr()?);
    let (requests, captured) = mpsc::channel();
    let router = Router::new()
        .route("/token", post(token))
        .with_state((response, requests));
    let server = tokio::spawn(async move { axum::serve(listener, router).await });
    let provider = ResolvedProvider {
        config: config.resolve(),
        generic: None,
    };
    let result = async {
        let code = validate_authorization_code_via_provider(
            &provider,
            "ordinary-code",
            callback,
            Some(verifier),
            Some("ordinary-device"),
        )
        .await?;
        let refresh = refresh_tokens_via_provider(
            &provider,
            &"ordinary-refresh".into(),
            &AuthRequest::new(HttpMethod::Post, "/refresh-token"),
        )
        .await?;
        Ok((
            captured.try_iter().collect(),
            json!([token_json(code), token_json(refresh)]),
        ))
    }
    .await;
    server.abort();
    result
}

#[tokio::test]
async fn discord_code_and_refresh_match_the_pinned_ordinary_contract() -> TestResult<()> {
    let mut config = OAuthProvider::discord("client", "secret");
    config.redirect_uri = Some("https://app.example.test/configured-callback".into());
    let response = json!({
        "access_token": "ordinary-access",
        "refresh_token": "ordinary-refresh",
        "token_type": "Bearer",
    });
    let (requests, tokens) = grants(
        config,
        "https://app.example.test/callback/discord",
        "ordinary-code-verifier-at-least-forty-three-characters",
        response.clone(),
    )
    .await?;
    assert_eq!(
        requests,
        vec![
            json!({
                "method": "POST", "authorization": null,
                "contentType": "application/x-www-form-urlencoded",
                "accept": "application/json",
                "body": {
                    "grant_type": "authorization_code", "code": "ordinary-code",
                    "redirect_uri": "https://app.example.test/configured-callback",
                    "client_id": "client", "client_secret": "secret",
                },
            }),
            json!({
                "method": "POST", "authorization": null,
                "contentType": "application/x-www-form-urlencoded",
                "accept": "application/json",
                "body": {
                    "grant_type": "refresh_token", "refresh_token": "ordinary-refresh",
                    "client_id": "client", "client_secret": "secret",
                },
            }),
        ]
    );
    assert_eq!(
        tokens,
        json!([
            {"tokenType":"Bearer", "accessToken":"ordinary-access", "refreshToken":"ordinary-refresh", "scopes":[], "raw":response},
            {"tokenType":"Bearer", "accessToken":"ordinary-access", "refreshToken":"ordinary-refresh", "scopes":[]},
        ])
    );
    Ok(())
}

#[tokio::test]
async fn social_line_code_and_refresh_match_the_captured_requests_and_tokens() -> TestResult<()> {
    let fixture: Value = serde_json::from_str(include_str!(
        "../../../../../tests/fixtures/social-line-1.7.6.json"
    ))?;
    let expected_requests = fixture["requests"]
        .as_array()
        .ok_or("missing LINE requests")?
        .iter()
        .filter(|request| request["url"] == fixture["endpoints"]["token"])
        .map(|request| {
            let body: BTreeMap<String, String> = url::form_urlencoded::parse(
                request["body"]
                    .as_str()
                    .ok_or("missing LINE request 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 code_request = expected_requests
        .first()
        .ok_or("missing LINE code request")?;
    let config = OAuthProvider::line(
        fixture["clientId"]
            .as_str()
            .ok_or("missing LINE client ID")?,
        fixture["clientSecret"]
            .as_str()
            .ok_or("missing LINE client secret")?,
    );
    let (requests, tokens) = grants(
        config,
        fixture["callbackURL"]
            .as_str()
            .ok_or("missing LINE callback URL")?,
        code_request["body"]["code_verifier"]
            .as_str()
            .ok_or("missing LINE verifier")?,
        fixture["grantTokens"][0]["raw"].clone(),
    )
    .await?;
    assert_eq!(requests, expected_requests);
    assert_eq!(tokens, fixture["grantTokens"]);
    Ok(())
}

fn client_key_requests(
    client_key: Option<&str>,
    basic: bool,
    code_verifier: bool,
    code_accept: &str,
) -> Vec<Value> {
    let mut code = json!({
        "grant_type": "authorization_code",
        "code": "ordinary-code",
        "redirect_uri": "https://app.example.test/callback/provider",
    });
    if let Some(key) = client_key {
        code["client_key"] = json!(key);
    }
    if code_verifier {
        code["code_verifier"] = json!("ordinary-code-verifier");
    }
    let mut refresh = json!({
        "grant_type": "refresh_token",
        "refresh_token": "ordinary-refresh",
    });
    if !basic {
        for body in [&mut code, &mut refresh] {
            body["client_id"] = json!("client");
            body["client_secret"] = json!("secret");
        }
    }
    let authorization = basic.then_some("Basic Y2xpZW50OnNlY3JldA==");
    [(code, code_accept), (refresh, "application/json")]
        .into_iter()
        .map(|(body, accept)| {
            json!({
                "method": "POST",
                "contentType": "application/x-www-form-urlencoded",
                "accept": accept,
                "authorization": authorization,
                "body": body,
            })
        })
        .collect()
}

#[tokio::test]
async fn social_client_key_matches_pinned_code_and_refresh_option_paths() -> TestResult<()> {
    let key = Some("ordinary-client-key");
    for (name, mut config, configured_key, sent_key, basic, verifier, accept) in [
        (
            "discord",
            OAuthProvider::discord("client", "secret"),
            key,
            key,
            false,
            false,
            "application/json",
        ),
        (
            "railway",
            OAuthProvider::railway("client", "secret"),
            key,
            key,
            true,
            true,
            "application/json",
        ),
        (
            "line",
            OAuthProvider::line("client", "secret"),
            key,
            key,
            false,
            true,
            "application/json",
        ),
        (
            "paypal",
            OAuthProvider::paypal("client", "secret"),
            key,
            None,
            true,
            true,
            "application/json",
        ),
        (
            "reddit",
            OAuthProvider::reddit("client", "secret"),
            key,
            None,
            true,
            false,
            "text/plain",
        ),
        (
            "discord omitted",
            OAuthProvider::discord("client", "secret"),
            None,
            None,
            false,
            false,
            "application/json",
        ),
        (
            "discord empty",
            OAuthProvider::discord("client", "secret"),
            Some(""),
            None,
            false,
            false,
            "application/json",
        ),
    ] {
        config.client_key = configured_key.map(str::to_owned);
        let response = json!({
            "access_token": "ordinary-access",
            "refresh_token": "ordinary-refresh",
            "token_type": "Bearer",
        });
        let (requests, tokens) = grants(
            config,
            "https://app.example.test/callback/provider",
            "ordinary-code-verifier",
            response.clone(),
        )
        .await?;
        assert_eq!(
            requests,
            client_key_requests(sent_key, basic, verifier, accept),
            "{name}",
        );
        assert_eq!(
            tokens,
            json!([
                {"tokenType":"Bearer", "accessToken":"ordinary-access", "refreshToken":"ordinary-refresh", "scopes":[], "raw":response},
                {"tokenType":"Bearer", "accessToken":"ordinary-access", "refreshToken":"ordinary-refresh", "scopes":[]},
            ]),
            "{name}",
        );
    }
    Ok(())
}