openauth-plugins 0.0.4

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

use http::{header, Method, Request, StatusCode};
use openauth_core::api::{core_auth_async_endpoints, AuthRouter};
use openauth_core::context::{create_auth_context, create_auth_context_with_adapter};
use openauth_core::cookies::{parse_set_cookie_header, set_session_cookie, SessionCookieOptions};
use openauth_core::crypto::password::hash_password;
use openauth_core::crypto::symmetric_decrypt;
use openauth_core::db::{
    Create, DbAdapter, DbRecord, DbValue, Delete, FindOne, MemoryAdapter, Where,
};
use openauth_core::error::OpenAuthError;
use openauth_core::options::{AdvancedOptions, OpenAuthOptions};
use openauth_plugins::two_factor::{totp_code, two_factor, TwoFactorOptions};
use serde_json::Value;
use time::OffsetDateTime;

pub(super) async fn seeded_router(
) -> Result<(Arc<MemoryAdapter>, AuthRouter), Box<dyn std::error::Error>> {
    seeded_router_with_options(TwoFactorOptions::default()).await
}

pub(super) async fn seeded_router_with_options(
    two_factor_options: TwoFactorOptions,
) -> Result<(Arc<MemoryAdapter>, AuthRouter), Box<dyn std::error::Error>> {
    let adapter = Arc::new(MemoryAdapter::new());
    seed_user(adapter.as_ref()).await?;
    let context = create_auth_context_with_adapter(
        options_with_two_factor(two_factor_options),
        adapter.clone(),
    )?;
    let router = AuthRouter::with_async_endpoints(
        context,
        Vec::new(),
        core_auth_async_endpoints(adapter.clone()),
    )?;
    Ok((adapter, router))
}

pub(super) async fn enable_totp(
    adapter: &MemoryAdapter,
    router: &AuthRouter,
) -> Result<String, Box<dyn std::error::Error>> {
    enable_totp_in_table(adapter, router, "twoFactor").await
}

pub(super) async fn enable_totp_in_table(
    adapter: &MemoryAdapter,
    router: &AuthRouter,
    table: &str,
) -> Result<String, Box<dyn std::error::Error>> {
    let cookie = sign_in_cookie(router).await?;
    let response = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/two-factor/enable",
            r#"{"password":"password123"}"#,
            Some(&cookie),
        )?)
        .await?;
    assert_eq!(response.status(), StatusCode::OK);
    let record = two_factor_record_in(adapter, table).await?;
    let secret = symmetric_decrypt(secret(), string_field(&record, "secret")?)?;
    let code = totp_code(&secret, 6, 30, OffsetDateTime::now_utc().unix_timestamp());
    let response = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/two-factor/verify-totp",
            &format!(r#"{{"code":"{code}"}}"#),
            Some(&cookie),
        )?)
        .await?;
    assert_eq!(response.status(), StatusCode::OK);
    Ok(cookie)
}

pub(super) fn options() -> OpenAuthOptions {
    options_with_two_factor(TwoFactorOptions::default())
}

pub(super) fn options_with_two_factor(two_factor_options: TwoFactorOptions) -> OpenAuthOptions {
    OpenAuthOptions {
        secret: Some(secret().to_owned()),
        advanced: AdvancedOptions {
            disable_csrf_check: true,
            disable_origin_check: true,
            ..AdvancedOptions::default()
        },
        plugins: vec![two_factor(two_factor_options)],
        ..OpenAuthOptions::default()
    }
}

pub(super) async fn sign_in_cookie(
    router: &AuthRouter,
) -> Result<String, Box<dyn std::error::Error>> {
    let response = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/sign-in/email",
            r#"{"email":"ada@example.com","password":"password123"}"#,
            None,
        )?)
        .await?;
    Ok(cookie_header_from_response(&response))
}

pub(super) async fn passwordless_session_cookie(
    adapter: &MemoryAdapter,
) -> Result<String, Box<dyn std::error::Error>> {
    adapter
        .delete(
            Delete::new("account")
                .where_clause(Where::new("user_id", DbValue::String("user_1".to_owned()))),
        )
        .await?;
    let now = OffsetDateTime::now_utc();
    adapter
        .create(
            Create::new("session")
                .data("id", DbValue::String("session_1".to_owned()))
                .data("user_id", DbValue::String("user_1".to_owned()))
                .data("token", DbValue::String("token_1".to_owned()))
                .data(
                    "expires_at",
                    DbValue::Timestamp(now + time::Duration::hours(1)),
                )
                .data("ip_address", DbValue::Null)
                .data("user_agent", DbValue::Null)
                .data("created_at", DbValue::Timestamp(now))
                .data("updated_at", DbValue::Timestamp(now))
                .force_allow_id(),
        )
        .await?;
    let context = create_auth_context(OpenAuthOptions {
        secret: Some(secret().to_owned()),
        ..OpenAuthOptions::default()
    })?;
    let cookies = set_session_cookie(
        &context.auth_cookies,
        &context.secret,
        "token_1",
        SessionCookieOptions::default(),
    )?;
    Ok(cookies
        .into_iter()
        .map(|cookie| format!("{}={}", cookie.name, cookie.value))
        .collect::<Vec<_>>()
        .join("; "))
}

pub(super) async fn two_factor_challenge_cookie(
    router: &AuthRouter,
) -> Result<(String, Value), Box<dyn std::error::Error>> {
    let response = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/sign-in/email",
            r#"{"email":"ada@example.com","password":"password123"}"#,
            None,
        )?)
        .await?;
    assert_eq!(response.status(), StatusCode::OK);
    let body = serde_json::from_slice(response.body())?;
    Ok((cookie_header_from_response(&response), body))
}

pub(super) 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}"))
        .header(header::CONTENT_TYPE, "application/json");
    if let Some(cookie) = cookie {
        builder = builder.header(header::COOKIE, cookie);
    }
    builder.body(body.as_bytes().to_vec())
}

pub(super) fn cookie_header_from_response(response: &http::Response<Vec<u8>>) -> String {
    set_cookie_values(response)
        .iter()
        .filter_map(|value| {
            let parsed = parse_set_cookie_header(value);
            parsed
                .into_iter()
                .next()
                .map(|(name, cookie)| format!("{name}={}", cookie.value))
        })
        .collect::<Vec<_>>()
        .join("; ")
}

pub(super) 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(super) fn cookie_value_from_response(
    response: &http::Response<Vec<u8>>,
    name_suffix: &str,
) -> Option<String> {
    set_cookie_values(response).iter().find_map(|value| {
        parse_set_cookie_header(value)
            .into_iter()
            .find_map(|(name, cookie)| name.ends_with(name_suffix).then_some(cookie.value))
    })
}

pub(super) async fn two_factor_record(adapter: &MemoryAdapter) -> Result<DbRecord, OpenAuthError> {
    two_factor_record_in(adapter, "twoFactor").await
}

pub(super) async fn two_factor_record_in(
    adapter: &MemoryAdapter,
    table: &str,
) -> Result<DbRecord, OpenAuthError> {
    adapter
        .find_one(
            FindOne::new(table)
                .where_clause(Where::new("user_id", DbValue::String("user_1".to_owned()))),
        )
        .await?
        .ok_or_else(|| OpenAuthError::Adapter("missing two factor record".to_owned()))
}

pub(super) async fn user_enabled(adapter: &MemoryAdapter) -> Result<bool, OpenAuthError> {
    let record = adapter
        .find_one(
            FindOne::new("user")
                .where_clause(Where::new("id", DbValue::String("user_1".to_owned()))),
        )
        .await?
        .ok_or_else(|| OpenAuthError::Adapter("missing user".to_owned()))?;
    Ok(matches!(
        record.get("two_factor_enabled"),
        Some(DbValue::Boolean(true))
    ))
}

pub(super) fn string_field<'a>(
    record: &'a DbRecord,
    field: &str,
) -> Result<&'a str, OpenAuthError> {
    match record.get(field) {
        Some(DbValue::String(value)) => Ok(value),
        _ => Err(OpenAuthError::Adapter(format!(
            "missing string field {field}"
        ))),
    }
}

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

async fn seed_user(adapter: &MemoryAdapter) -> Result<(), OpenAuthError> {
    let now = OffsetDateTime::now_utc();
    adapter
        .create(
            Create::new("user")
                .data("id", DbValue::String("user_1".to_owned()))
                .data("name", DbValue::String("Ada".to_owned()))
                .data("email", DbValue::String("ada@example.com".to_owned()))
                .data("email_verified", DbValue::Boolean(true))
                .data("image", DbValue::Null)
                .data("two_factor_enabled", DbValue::Boolean(false))
                .data("created_at", DbValue::Timestamp(now))
                .data("updated_at", DbValue::Timestamp(now))
                .force_allow_id(),
        )
        .await?;
    adapter
        .create(
            Create::new("account")
                .data("id", DbValue::String("account_1".to_owned()))
                .data("provider_id", DbValue::String("credential".to_owned()))
                .data("account_id", DbValue::String("user_1".to_owned()))
                .data("user_id", DbValue::String("user_1".to_owned()))
                .data("access_token", DbValue::Null)
                .data("refresh_token", DbValue::Null)
                .data("id_token", DbValue::Null)
                .data("access_token_expires_at", DbValue::Null)
                .data("refresh_token_expires_at", DbValue::Null)
                .data("scope", DbValue::Null)
                .data("password", DbValue::String(hash_password("password123")?))
                .data("created_at", DbValue::Timestamp(now))
                .data("updated_at", DbValue::Timestamp(now))
                .force_allow_id(),
        )
        .await?;
    Ok(())
}