better-auth-api 1.0.0-alpha.5

Plugin implementations for better-auth
use super::{
    OAuthProvider, OAuthTokenSet, OAuthUserInfoRequest,
    authorization::{AuthorizationRequest, build_authorization_url},
    provider_tokens::{refresh_tokens_via_provider, validate_authorization_code_via_provider},
    resolved::ResolvedProvider,
    social_profile::fetch_user_info_for_code,
};
use axum::{
    Json, Router,
    extract::State,
    http::{HeaderMap, Method, StatusCode, Uri},
};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use better_auth_core::{AuthRequest, HttpMethod};
use serde::Deserialize;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::{collections::BTreeMap, error::Error, fmt::Debug, sync::mpsc};
use url::Url;

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

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Options {
    issuer: Option<String>,
}

#[derive(Debug, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Request {
    url: String,
    method: String,
    content_type: Option<String>,
    authorization: Option<String>,
    body: Option<BTreeMap<String, String>>,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Case {
    name: String,
    options: Options,
    #[serde(rename = "authorizationURL")]
    authorization_url: String,
    requests: Vec<Request>,
    code_tokens: Value,
    refresh_tokens: Value,
    user_info: Value,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Fixture {
    version: String,
    client_id: String,
    client_secret: String,
    #[serde(rename = "callbackURL")]
    callback_url: String,
    code_verifier: String,
    state: String,
    profile: Value,
    token_response: Value,
    cases: Vec<Case>,
}

#[derive(Clone)]
struct ServerState {
    token_origin: String,
    profile_origin: String,
    token_response: Value,
    profile: Value,
    requests: mpsc::Sender<Request>,
}

async fn respond(
    State(state): State<ServerState>,
    method: Method,
    uri: Uri,
    headers: HeaderMap,
    body: String,
) -> Result<Json<Value>, StatusCode> {
    let (origin, response) = match method {
        Method::POST => (&state.token_origin, state.token_response.clone()),
        Method::GET => (&state.profile_origin, state.profile.clone()),
        _ => return Err(StatusCode::METHOD_NOT_ALLOWED),
    };
    let header = |name| {
        headers
            .get(name)
            .map(|value| value.to_str().map(str::to_owned))
            .transpose()
            .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
    };
    state
        .requests
        .send(Request {
            url: format!("{origin}{uri}"),
            method: method.to_string(),
            content_type: header("content-type")?,
            authorization: header("authorization")?,
            body: (!body.is_empty()).then(|| {
                url::form_urlencoded::parse(body.as_bytes())
                    .into_owned()
                    .collect()
            }),
        })
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(response))
}

fn same<T: Debug + PartialEq>(actual: &T, expected: &T, label: &str) -> TestResult<()> {
    if actual != expected {
        return Err(format!("{label}: actual {actual:?}; expected {expected:?}").into());
    }
    Ok(())
}

fn route_locally(endpoint: &mut String, local_origin: &str) -> TestResult<String> {
    let origin = Url::parse(endpoint)?.origin().ascii_serialization();
    let path = endpoint
        .strip_prefix(&origin)
        .ok_or("Expected a URL origin prefix")?;
    // Preserve the constructed path; restore only the observed request's configured origin.
    *endpoint = format!("{local_origin}{path}");
    Ok(origin)
}

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

async fn check_case(fixture: &Fixture, case: &Case) -> TestResult<()> {
    let config = match case.options.issuer.as_deref() {
        Some(issuer) => {
            OAuthProvider::gitlab_with_issuer(&fixture.client_id, &fixture.client_secret, issuer)
        }
        None => OAuthProvider::gitlab(&fixture.client_id, &fixture.client_secret),
    };
    let mut provider = ResolvedProvider {
        config: config.resolve(),
        generic: None,
    };
    let code_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(fixture.code_verifier.as_bytes()));
    let authorization = build_authorization_url(
        &provider,
        AuthorizationRequest {
            callback_url: &fixture.callback_url,
            scopes: None,
            state: &fixture.state,
            code_challenge: &code_challenge,
            login_hint: None,
            nonce: None,
            additional_params: None,
        },
    )?;
    same(
        &authorization,
        &case.authorization_url,
        &format!("{} authorization URL", case.name),
    )?;

    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
    let local_origin = format!("http://{}", listener.local_addr()?);
    let token_origin = route_locally(&mut provider.config.token_url, &local_origin)?;
    let profile_origin = route_locally(
        provider
            .config
            .user_info_url
            .as_mut()
            .ok_or("Expected the GitLab profile endpoint")?,
        &local_origin,
    )?;
    let (requests, captured) = mpsc::channel();
    let router = Router::new().fallback(respond).with_state(ServerState {
        token_origin,
        profile_origin,
        token_response: fixture.token_response.clone(),
        profile: fixture.profile.clone(),
        requests,
    });
    let server = tokio::spawn(async move { axum::serve(listener, router).await });
    let result = async {
        let code = validate_authorization_code_via_provider(
            &provider,
            "ordinary-code",
            &fixture.callback_url,
            Some(&fixture.code_verifier),
            None,
        )
        .await?;
        same(
            &token_json(code),
            &case.code_tokens,
            &format!("{} code tokens", case.name),
        )?;
        let refresh = refresh_tokens_via_provider(
            &provider,
            &"ordinary-refresh".into(),
            &AuthRequest::new(HttpMethod::Post, "/refresh-token"),
        )
        .await?;
        same(
            &token_json(refresh),
            &case.refresh_tokens,
            &format!("{} refresh tokens", case.name),
        )?;
        let profile_request = OAuthUserInfoRequest {
            access_token: (Some("ordinary-access".into())).into(),
            ..Default::default()
        };
        let info = fetch_user_info_for_code(&provider, profile_request, None).await?;
        same(
            &crate::plugins::oauth::profile_test_support::json(info),
            &case.user_info,
            &format!("{} profile", case.name),
        )?;
        same(
            &captured.try_iter().collect(),
            &case.requests,
            &format!("{} request targets and forms", case.name),
        )
    }
    .await;
    server.abort();
    result
}

#[tokio::test]
async fn gitlab_issuer_matches_upstream_authorization_grants_and_profile_targets() -> TestResult<()>
{
    let fixture: Fixture = serde_json::from_str(&std::fs::read_to_string(concat!(
        env!("CARGO_MANIFEST_DIR"),
        "/../../tests/fixtures/gitlab-issuer-1.7.6.json"
    ))?)?;
    same(
        &fixture.version.as_str(),
        &"1.7.6",
        "Pinned upstream version",
    )?;
    same(
        &fixture
            .cases
            .iter()
            .map(|case| case.name.as_str())
            .collect::<Vec<_>>(),
        &vec![
            "omitted",
            "empty",
            "self-hosted",
            "trailing-slash",
            "subpath",
        ],
        "Complete issuer matrix",
    )?;
    for case in &fixture.cases {
        check_case(&fixture, case).await?;
    }
    Ok(())
}