openauth-plugins 0.0.4

Official OpenAuth plugin modules.
Documentation
use std::sync::Arc;

use http::{header, Method, Request};
use openauth_core::api::{core_auth_async_endpoints, AuthRouter};
use openauth_core::context::create_auth_context_with_adapter;
use openauth_core::cookies::sign_cookie_value;
use openauth_core::crypto::password::hash_password;
use openauth_core::db::{Create, DbAdapter, DbRecord, DbValue, MemoryAdapter, User};
use openauth_core::error::OpenAuthError;
use openauth_core::options::{
    AdvancedOptions, CookieCacheOptions, OpenAuthOptions, SessionOptions,
};
use openauth_core::session::{CreateSessionInput, DbSessionStore};
use openauth_plugins::multi_session::{multi_session_with_config, MultiSessionConfig};
use serde_json::Value;
use time::{Duration, OffsetDateTime};

pub struct Fixture {
    pub adapter: Arc<MemoryAdapter>,
    router: AuthRouter,
}

impl Fixture {
    pub async fn new(config: MultiSessionConfig) -> Result<Self, Box<dyn std::error::Error>> {
        Self::with_options(config, OpenAuthOptions::default()).await
    }

    pub async fn with_cookie_cache(
        config: MultiSessionConfig,
    ) -> Result<Self, Box<dyn std::error::Error>> {
        Self::with_options(
            config,
            OpenAuthOptions {
                session: SessionOptions {
                    cookie_cache: CookieCacheOptions {
                        enabled: true,
                        ..CookieCacheOptions::default()
                    },
                    ..SessionOptions::default()
                },
                ..OpenAuthOptions::default()
            },
        )
        .await
    }

    pub async fn with_options(
        config: MultiSessionConfig,
        options: OpenAuthOptions,
    ) -> Result<Self, Box<dyn std::error::Error>> {
        let adapter = Arc::new(MemoryAdapter::new());
        seed_user(&adapter, "user_1", "Ada", "ada@example.com").await?;
        seed_user(&adapter, "user_2", "Grace", "grace@example.com").await?;
        let context = create_auth_context_with_adapter(
            OpenAuthOptions {
                secret: Some(secret().to_owned()),
                plugins: vec![multi_session_with_config(config)],
                advanced: AdvancedOptions {
                    disable_csrf_check: true,
                    disable_origin_check: true,
                    ..AdvancedOptions::default()
                },
                ..options
            },
            adapter.clone(),
        )?;
        let router = AuthRouter::with_async_endpoints(
            context,
            Vec::new(),
            core_auth_async_endpoints(adapter.clone()),
        )?;
        Ok(Self { adapter, router })
    }

    pub async fn sign_in(
        &self,
        email: &str,
        password: &str,
        cookie: Option<&str>,
    ) -> Result<http::Response<Vec<u8>>, OpenAuthError> {
        self.sign_in_with_body(
            &format!(r#"{{"email":"{email}","password":"{password}"}}"#),
            cookie,
        )
        .await
    }

    pub async fn sign_in_with_body(
        &self,
        body: &str,
        cookie: Option<&str>,
    ) -> Result<http::Response<Vec<u8>>, OpenAuthError> {
        self.request(Method::POST, "/api/auth/sign-in/email", body, cookie)
            .await
    }

    pub async fn sign_up(
        &self,
        email: &str,
        cookie: Option<&str>,
    ) -> Result<http::Response<Vec<u8>>, OpenAuthError> {
        self.request(
            Method::POST,
            "/api/auth/sign-up/email",
            &format!(r#"{{"name":"Linus","email":"{email}","password":"secret123"}}"#),
            cookie,
        )
        .await
    }

    pub async fn create_expired_session(
        &self,
        user_id: &str,
        token: &str,
    ) -> Result<(), OpenAuthError> {
        DbSessionStore::new(self.adapter.as_ref())
            .create_session(
                CreateSessionInput::new(user_id, OffsetDateTime::now_utc() - Duration::hours(1))
                    .token(token),
            )
            .await?;
        Ok(())
    }

    pub async fn request(
        &self,
        method: Method,
        path: &str,
        body: &str,
        cookie: Option<&str>,
    ) -> Result<http::Response<Vec<u8>>, OpenAuthError> {
        self.router
            .handle_async(
                json_request(method, path, body, cookie)
                    .map_err(|error| OpenAuthError::Api(error.to_string()))?,
            )
            .await
    }

    pub fn openapi_schema(&self) -> Value {
        self.router.openapi_schema()
    }
}

async fn seed_user(
    adapter: &MemoryAdapter,
    id: &str,
    name: &str,
    email: &str,
) -> Result<(), OpenAuthError> {
    let now = OffsetDateTime::now_utc();
    adapter
        .create(create_query("user", user_record(id, name, email, now)))
        .await?;
    adapter
        .create(create_query(
            "account",
            credential_account_record(id, &hash_password("secret123")?, now),
        ))
        .await?;
    Ok(())
}

fn json_request(
    method: Method,
    path: &str,
    body: &str,
    cookie: Option<&str>,
) -> Result<Request<Vec<u8>>, http::Error> {
    let mut builder = Request::builder()
        .method(method)
        .uri(format!("http://localhost:3000{path}"));
    if !body.is_empty() {
        builder = builder.header(header::CONTENT_TYPE, "application/json");
    }
    if let Some(cookie) = cookie {
        builder = builder.header(header::COOKIE, cookie);
    }
    builder.body(body.as_bytes().to_vec())
}

fn user_record(id: &str, name: &str, email: &str, now: OffsetDateTime) -> DbRecord {
    let user = User {
        id: id.to_owned(),
        name: name.to_owned(),
        email: email.to_owned(),
        email_verified: true,
        image: None,
        username: None,
        display_username: None,
        created_at: now,
        updated_at: now,
    };
    let mut record = DbRecord::new();
    record.insert("id".to_owned(), DbValue::String(user.id));
    record.insert("name".to_owned(), DbValue::String(user.name));
    record.insert("email".to_owned(), DbValue::String(user.email));
    record.insert("email_verified".to_owned(), DbValue::Boolean(true));
    record.insert("image".to_owned(), DbValue::Null);
    record.insert("username".to_owned(), DbValue::Null);
    record.insert("display_username".to_owned(), DbValue::Null);
    record.insert("created_at".to_owned(), DbValue::Timestamp(user.created_at));
    record.insert("updated_at".to_owned(), DbValue::Timestamp(user.updated_at));
    record
}

fn credential_account_record(user_id: &str, password_hash: &str, now: OffsetDateTime) -> DbRecord {
    let mut record = DbRecord::new();
    record.insert(
        "id".to_owned(),
        DbValue::String(format!("account_{user_id}")),
    );
    record.insert(
        "provider_id".to_owned(),
        DbValue::String("credential".to_owned()),
    );
    record.insert("account_id".to_owned(), DbValue::String(user_id.to_owned()));
    record.insert("user_id".to_owned(), DbValue::String(user_id.to_owned()));
    record.insert("access_token".to_owned(), DbValue::Null);
    record.insert("refresh_token".to_owned(), DbValue::Null);
    record.insert("id_token".to_owned(), DbValue::Null);
    record.insert("access_token_expires_at".to_owned(), DbValue::Null);
    record.insert("refresh_token_expires_at".to_owned(), DbValue::Null);
    record.insert("scope".to_owned(), DbValue::Null);
    record.insert(
        "password".to_owned(),
        DbValue::String(password_hash.to_owned()),
    );
    record.insert("created_at".to_owned(), DbValue::Timestamp(now));
    record.insert("updated_at".to_owned(), DbValue::Timestamp(now));
    record
}

fn create_query(model: &str, record: DbRecord) -> Create {
    record
        .into_iter()
        .fold(Create::new(model), |query, (field, value)| {
            query.data(field, value)
        })
}

pub fn response_token(
    response: &http::Response<Vec<u8>>,
) -> Result<String, Box<dyn std::error::Error>> {
    let body: Value = serde_json::from_slice(response.body())?;
    body["token"]
        .as_str()
        .map(str::to_owned)
        .ok_or_else(|| "missing token".into())
}

pub fn set_cookie_values(response: &http::Response<Vec<u8>>) -> Vec<String> {
    response
        .headers()
        .get_all(header::SET_COOKIE)
        .iter()
        .filter_map(|value| value.to_str().ok().map(str::to_owned))
        .collect()
}

pub fn cookie_header_from_response(response: &http::Response<Vec<u8>>) -> String {
    set_cookie_values(response)
        .into_iter()
        .filter_map(|cookie| cookie.split_once(';').map(|(pair, _)| pair.to_owned()))
        .collect::<Vec<_>>()
        .join("; ")
}

pub fn merge_cookie_headers(headers: &[&str]) -> String {
    headers
        .iter()
        .flat_map(|header| header.split("; "))
        .filter(|cookie| !cookie.is_empty())
        .collect::<Vec<_>>()
        .join("; ")
}

pub fn multi_cookie_name(token: &str) -> String {
    format!("better-auth.session_token_multi-{}", token.to_lowercase())
}

pub fn signed_multi_cookie(token: &str) -> Result<String, OpenAuthError> {
    Ok(format!(
        "{}={}",
        multi_cookie_name(token),
        sign_cookie_value(token, secret())?
    ))
}

fn secret() -> &'static str {
    "test-secret-123456789012345678901234"
}