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_with_adapter;
use openauth_core::db::{DbField, DbFieldType, MemoryAdapter, TableOptions};
use openauth_core::options::OpenAuthOptions;
use openauth_plugins::organization::{
    has_permission, organization, organization_with_options, OrganizationOptions,
    OrganizationPermission, OrganizationRole, OrganizationSchemaOptions,
};
use serde_json::{json, Value};

mod additional_fields;
mod dynamic_access_control;
mod hooks;
mod openapi;
mod session;
mod teams;

#[test]
fn exposes_organization_plugin_surface() -> Result<(), Box<dyn std::error::Error>> {
    assert_eq!(
        openauth_plugins::organization::UPSTREAM_PLUGIN_ID,
        "organization"
    );

    let plugin = organization();
    assert_eq!(plugin.id, "organization");
    assert!(plugin
        .endpoints
        .iter()
        .any(|endpoint| endpoint.path == "/organization/create"));
    assert!(plugin
        .error_codes
        .iter()
        .any(|code| code.code == "ORGANIZATION_NOT_FOUND"));
    Ok(())
}

#[test]
fn organization_schema_registers_core_tables_and_session_field(
) -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(MemoryAdapter::new());
    let context = create_auth_context_with_adapter(
        OpenAuthOptions {
            plugins: vec![organization()],
            secret: Some("secret-a-at-least-32-chars-long!!".to_owned()),
            ..OpenAuthOptions::default()
        },
        adapter,
    )?;

    assert!(context.db_schema.table("organization").is_some());
    assert!(context.db_schema.table("member").is_some());
    assert!(context.db_schema.table("invitation").is_some());
    assert_eq!(
        context
            .db_schema
            .field_name("session", "active_organization_id")?,
        "active_organization_id"
    );
    Ok(())
}

#[test]
fn organization_schema_applies_custom_table_field_and_additional_field_metadata(
) -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(MemoryAdapter::new());
    let schema = OrganizationSchemaOptions {
        organization: TableOptions::default()
            .with_name("tenant_orgs")
            .with_field_name("slug", "tenant_slug")
            .with_field(
                "billing_code",
                DbField::new("billing_code", DbFieldType::String)
                    .optional()
                    .hidden(),
            ),
        ..OrganizationSchemaOptions::default()
    };
    let context = create_auth_context_with_adapter(
        OpenAuthOptions {
            plugins: vec![organization_with_options(
                OrganizationOptions::builder().schema(schema).build(),
            )],
            secret: Some("secret-a-at-least-32-chars-long!!".to_owned()),
            ..OpenAuthOptions::default()
        },
        adapter,
    )?;

    assert_eq!(context.db_schema.table_name("organization")?, "tenant_orgs");
    assert_eq!(
        context.db_schema.field_name("organization", "slug")?,
        "tenant_slug"
    );
    let field = context.db_schema.field("organization", "billing_code")?;
    assert_eq!(field.name, "billing_code");
    assert!(!field.returned);
    Ok(())
}

#[test]
fn default_permissions_match_upstream_roles() {
    let options = OrganizationOptions::default();
    assert!(has_permission(
        OrganizationRole::Owner.as_str(),
        &options,
        OrganizationPermission::OrganizationDelete,
    ));
    assert!(has_permission(
        OrganizationRole::Admin.as_str(),
        &options,
        OrganizationPermission::MemberCreate,
    ));
    assert!(!has_permission(
        OrganizationRole::Member.as_str(),
        &options,
        OrganizationPermission::MemberCreate,
    ));
}

#[tokio::test]
async fn organization_routes_cover_create_members_and_invitations(
) -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(MemoryAdapter::new());
    let auth = test_router(adapter.clone(), OrganizationOptions::default())?;

    let ada = sign_up(&auth, "Ada", "ada@example.com").await?;
    let org = request_json(
        &auth,
        Method::POST,
        "/api/auth/organization/create",
        json!({"name":"Acme","slug":"acme"}),
        Some(&ada.cookie),
    )
    .await?;
    assert_eq!(org.status, StatusCode::OK);
    assert_eq!(org.body["slug"], "acme");
    assert_eq!(org.body["members"][0]["role"], "owner");

    let duplicate = request_json(
        &auth,
        Method::POST,
        "/api/auth/organization/create",
        json!({"name":"Acme 2","slug":"acme"}),
        Some(&ada.cookie),
    )
    .await?;
    assert_eq!(duplicate.status, StatusCode::BAD_REQUEST);

    let ben = sign_up(&auth, "Ben", "ben@example.com").await?;
    let invite = request_json(
        &auth,
        Method::POST,
        "/api/auth/organization/invite-member",
        json!({"email":"ben@example.com","role":"member"}),
        Some(&ada.cookie),
    )
    .await?;
    assert_eq!(invite.status, StatusCode::OK);
    let invitation_id = invite.body["id"]
        .as_str()
        .ok_or("missing invitation id")?
        .to_owned();

    let accepted = request_json(
        &auth,
        Method::POST,
        "/api/auth/organization/accept-invitation",
        json!({"invitationId": invitation_id}),
        Some(&ben.cookie),
    )
    .await?;
    assert_eq!(accepted.status, StatusCode::OK);
    assert_eq!(accepted.body["member"]["role"], "member");

    let members = request_json(
        &auth,
        Method::GET,
        "/api/auth/organization/list-members",
        json!({}),
        Some(&ada.cookie),
    )
    .await?;
    assert_eq!(members.status, StatusCode::OK);
    assert_eq!(members.body["members"].as_array().map(Vec::len), Some(2));

    let denied = request_json(
        &auth,
        Method::POST,
        "/api/auth/organization/delete",
        json!({"organizationId": org.body["id"]}),
        Some(&ben.cookie),
    )
    .await?;
    assert_eq!(denied.status, StatusCode::FORBIDDEN);

    Ok(())
}

fn test_router(
    adapter: Arc<MemoryAdapter>,
    options: OrganizationOptions,
) -> Result<AuthRouter, Box<dyn std::error::Error>> {
    let adapter_dyn: Arc<dyn openauth_core::db::DbAdapter> = adapter;
    let context = create_auth_context_with_adapter(
        OpenAuthOptions {
            plugins: vec![organization_with_options(options)],
            base_url: Some("http://localhost:3000".to_owned()),
            secret: Some("secret-a-at-least-32-chars-long!!".to_owned()),
            ..OpenAuthOptions::default()
        },
        adapter_dyn.clone(),
    )?;
    Ok(AuthRouter::with_async_endpoints(
        context,
        Vec::new(),
        core_auth_async_endpoints(adapter_dyn),
    )?)
}

struct TestResponse {
    status: StatusCode,
    body: Value,
    set_cookie: Option<String>,
}

struct SignedUp {
    cookie: String,
    user_id: String,
}

async fn sign_up(
    router: &AuthRouter,
    name: &str,
    email: &str,
) -> Result<SignedUp, Box<dyn std::error::Error>> {
    let response = request_json(
        router,
        Method::POST,
        "/api/auth/sign-up/email",
        json!({"name":name,"email":email,"password":"secret123"}),
        None,
    )
    .await?;
    assert_eq!(response.status, StatusCode::OK);
    let user_id = response.body["user"]["id"]
        .as_str()
        .ok_or("missing user id")?
        .to_owned();
    Ok(SignedUp {
        cookie: response.set_cookie.ok_or("missing session cookie")?,
        user_id,
    })
}

async fn request_json(
    router: &AuthRouter,
    method: Method,
    path: &str,
    body: Value,
    cookie: Option<&str>,
) -> Result<TestResponse, Box<dyn std::error::Error>> {
    let payload = if method == Method::GET && body == json!({}) {
        Vec::new()
    } else {
        serde_json::to_vec(&body)?
    };
    let mut builder = Request::builder()
        .method(method)
        .uri(format!("http://localhost:3000{path}"));
    if !payload.is_empty() {
        builder = builder
            .header(header::CONTENT_TYPE, "application/json")
            .header(header::ORIGIN, "http://localhost:3000");
    }
    if let Some(cookie) = cookie {
        builder = builder.header(header::COOKIE, cookie);
    }
    let response = router.handle_async(builder.body(payload)?).await?;
    let status = response.status();
    let set_cookie = response
        .headers()
        .get_all(header::SET_COOKIE)
        .iter()
        .filter_map(|value| value.to_str().ok())
        .find(|value| value.starts_with("better-auth.session_token="))
        .and_then(|value| value.split(';').next().map(str::to_owned));
    let body = if response.body().is_empty() {
        Value::Null
    } else {
        serde_json::from_slice(response.body())?
    };
    Ok(TestResponse {
        status,
        body,
        set_cookie,
    })
}

#[test]
fn organization_default_roles_include_upstream_roles(
) -> Result<(), openauth_plugins::access::AccessError> {
    let roles = openauth_plugins::organization::access::default_roles()?;

    assert!(roles.contains_key("admin"));
    assert!(roles.contains_key("owner"));
    assert!(roles.contains_key("member"));
    Ok(())
}

#[test]
fn organization_owner_can_delete_organization() -> Result<(), openauth_plugins::access::AccessError>
{
    let owner = openauth_plugins::organization::access::owner_role()?;

    assert_eq!(
        owner.authorize_all(openauth_plugins::access::request([(
            "organization",
            ["delete"]
        )])),
        Ok(())
    );
    Ok(())
}

#[test]
fn organization_admin_can_update_but_not_delete_organization(
) -> Result<(), openauth_plugins::access::AccessError> {
    let admin = openauth_plugins::organization::access::admin_role()?;

    assert_eq!(
        admin.authorize_all(openauth_plugins::access::request([(
            "organization",
            ["update"]
        )])),
        Ok(())
    );
    assert_eq!(
        admin.authorize_all(openauth_plugins::access::request([(
            "organization",
            ["delete"]
        )])),
        Err(
            openauth_plugins::access::AccessError::UnauthorizedResource {
                resource: "organization".to_string()
            }
        )
    );
    Ok(())
}

#[test]
fn organization_member_can_read_access_control_only(
) -> Result<(), openauth_plugins::access::AccessError> {
    let member = openauth_plugins::organization::access::member_role()?;

    assert_eq!(
        member.authorize_all(openauth_plugins::access::request([("ac", ["read"])])),
        Ok(())
    );
    assert_eq!(
        member.authorize_all(openauth_plugins::access::request([("member", ["create"])])),
        Err(
            openauth_plugins::access::AccessError::UnauthorizedResource {
                resource: "member".to_string()
            }
        )
    );
    Ok(())
}