better-auth 1.0.0-alpha.5

The most comprehensive authentication framework for Rust
use std::fs;
use std::path::PathBuf;

use better_auth::plugins::{
    AccountManagementPlugin, AdminPlugin, ApiKeyPlugin, DeviceAuthorizationPlugin,
    EmailPasswordPlugin, EmailVerificationPlugin, OAuthPlugin, OrganizationPlugin, PasskeyPlugin,
    PasswordManagementPlugin, SessionManagementPlugin, TwoFactorPlugin, UserManagementPlugin,
};
use better_auth::{AuthBuilder, AuthConfig, BetterAuth};
use better_auth_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
use better_auth_seaorm::store::__private_test_support::migrator::run_migrations;
use better_auth_seaorm::{Database, SeaOrmStore};
use clap::Parser;
use serde_json::Value;

type DynError = Box<dyn std::error::Error + Send + Sync + 'static>;

const DOCS_TITLE: &str = "Better Auth RS";
const DOCS_DESCRIPTION: &str = "OpenAPI schema for the better-auth-rs v1 docs surface.";

const DEFAULT_TAG: &str = "Default";
const USERNAME_TAG: &str = "Username";
const DEVICE_AUTHORIZATION_TAG: &str = "Device-authorization";
const API_KEY_TAG: &str = "Api-key";
const ORGANIZATION_TAG: &str = "Organization";
const PASSKEY_TAG: &str = "Passkey";
const ADMIN_TAG: &str = "Admin";
const TWO_FACTOR_TAG: &str = "Two-factor";

const V1_DOCS_TAGS: &[(&str, &str, &str)] = &[
    (
        DEFAULT_TAG,
        "Default",
        "Core v1 endpoints plus the default account and OAuth surfaces.",
    ),
    (
        USERNAME_TAG,
        "Username",
        "Username-based authentication endpoints.",
    ),
    (
        DEVICE_AUTHORIZATION_TAG,
        "Device Authorization",
        "Device authorization endpoints from the v1 compatibility surface.",
    ),
    (
        API_KEY_TAG,
        "Api key",
        "API Key plugin endpoints for creating, managing, and verifying API keys.",
    ),
    (
        ORGANIZATION_TAG,
        "Organization",
        "Organization endpoints for multi-tenant management, members, invitations, and RBAC.",
    ),
    (
        PASSKEY_TAG,
        "Passkey",
        "Passkey plugin endpoints for WebAuthn/FIDO2 passwordless authentication.",
    ),
    (
        ADMIN_TAG,
        "Admin",
        "Admin plugin endpoints for user management, banning, and impersonation.",
    ),
    (
        TWO_FACTOR_TAG,
        "Two factor",
        "Two-Factor Authentication plugin endpoints for TOTP, OTP, and backup codes.",
    ),
];

const V1_DOCS_PATHS: &[(&str, &str)] = &[
    ("/ok", DEFAULT_TAG),
    ("/error", DEFAULT_TAG),
    ("/update-user", DEFAULT_TAG),
    ("/delete-user", DEFAULT_TAG),
    ("/delete-user/callback", DEFAULT_TAG),
    ("/change-email", DEFAULT_TAG),
    ("/sign-up/email", DEFAULT_TAG),
    ("/sign-in/email", DEFAULT_TAG),
    ("/sign-in/social", DEFAULT_TAG),
    ("/callback/{id}", DEFAULT_TAG),
    ("/list-accounts", DEFAULT_TAG),
    ("/link-social", DEFAULT_TAG),
    ("/unlink-account", DEFAULT_TAG),
    ("/get-access-token", DEFAULT_TAG),
    ("/refresh-token", DEFAULT_TAG),
    ("/account-info", DEFAULT_TAG),
    ("/get-session", DEFAULT_TAG),
    ("/update-session", DEFAULT_TAG),
    ("/sign-out", DEFAULT_TAG),
    ("/list-sessions", DEFAULT_TAG),
    ("/revoke-session", DEFAULT_TAG),
    ("/revoke-sessions", DEFAULT_TAG),
    ("/revoke-other-sessions", DEFAULT_TAG),
    ("/request-password-reset", DEFAULT_TAG),
    ("/reset-password/{token}", DEFAULT_TAG),
    ("/reset-password", DEFAULT_TAG),
    ("/change-password", DEFAULT_TAG),
    ("/verify-password", DEFAULT_TAG),
    ("/send-verification-email", DEFAULT_TAG),
    ("/verify-email", DEFAULT_TAG),
    ("/sign-in/username", USERNAME_TAG),
    ("/is-username-available", USERNAME_TAG),
    ("/device/code", DEVICE_AUTHORIZATION_TAG),
    ("/device/token", DEVICE_AUTHORIZATION_TAG),
    ("/device", DEVICE_AUTHORIZATION_TAG),
    ("/device/approve", DEVICE_AUTHORIZATION_TAG),
    ("/device/deny", DEVICE_AUTHORIZATION_TAG),
    ("/api-key/create", API_KEY_TAG),
    ("/api-key/get", API_KEY_TAG),
    ("/api-key/list", API_KEY_TAG),
    ("/api-key/update", API_KEY_TAG),
    ("/api-key/delete", API_KEY_TAG),
    ("/organization/create", ORGANIZATION_TAG),
    ("/organization/check-slug", ORGANIZATION_TAG),
    ("/organization/update", ORGANIZATION_TAG),
    ("/organization/delete", ORGANIZATION_TAG),
    ("/organization/get-full-organization", ORGANIZATION_TAG),
    ("/organization/get-organization", ORGANIZATION_TAG),
    ("/organization/set-active", ORGANIZATION_TAG),
    ("/organization/list", ORGANIZATION_TAG),
    ("/organization/list-members", ORGANIZATION_TAG),
    ("/organization/get-active-member", ORGANIZATION_TAG),
    ("/organization/get-active-member-role", ORGANIZATION_TAG),
    ("/organization/update-member-role", ORGANIZATION_TAG),
    ("/organization/remove-member", ORGANIZATION_TAG),
    ("/organization/leave", ORGANIZATION_TAG),
    ("/organization/invite-member", ORGANIZATION_TAG),
    ("/organization/accept-invitation", ORGANIZATION_TAG),
    ("/organization/reject-invitation", ORGANIZATION_TAG),
    ("/organization/cancel-invitation", ORGANIZATION_TAG),
    ("/organization/get-invitation", ORGANIZATION_TAG),
    ("/organization/list-invitations", ORGANIZATION_TAG),
    ("/organization/list-user-invitations", ORGANIZATION_TAG),
    ("/organization/has-permission", ORGANIZATION_TAG),
    ("/passkey/generate-register-options", PASSKEY_TAG),
    ("/passkey/generate-authenticate-options", PASSKEY_TAG),
    ("/passkey/verify-registration", PASSKEY_TAG),
    ("/passkey/verify-authentication", PASSKEY_TAG),
    ("/passkey/list-user-passkeys", PASSKEY_TAG),
    ("/passkey/delete-passkey", PASSKEY_TAG),
    ("/passkey/update-passkey", PASSKEY_TAG),
    ("/admin/list-users", ADMIN_TAG),
    ("/admin/get-user", ADMIN_TAG),
    ("/admin/create-user", ADMIN_TAG),
    ("/admin/update-user", ADMIN_TAG),
    ("/admin/remove-user", ADMIN_TAG),
    ("/admin/set-user-password", ADMIN_TAG),
    ("/admin/set-role", ADMIN_TAG),
    ("/admin/has-permission", ADMIN_TAG),
    ("/admin/ban-user", ADMIN_TAG),
    ("/admin/unban-user", ADMIN_TAG),
    ("/admin/impersonate-user", ADMIN_TAG),
    ("/admin/stop-impersonating", ADMIN_TAG),
    ("/admin/list-user-sessions", ADMIN_TAG),
    ("/admin/revoke-user-session", ADMIN_TAG),
    ("/admin/revoke-user-sessions", ADMIN_TAG),
    ("/two-factor/enable", TWO_FACTOR_TAG),
    ("/two-factor/disable", TWO_FACTOR_TAG),
    ("/two-factor/get-totp-uri", TWO_FACTOR_TAG),
    ("/two-factor/verify-totp", TWO_FACTOR_TAG),
    ("/two-factor/send-otp", TWO_FACTOR_TAG),
    ("/two-factor/verify-otp", TWO_FACTOR_TAG),
    ("/two-factor/generate-backup-codes", TWO_FACTOR_TAG),
    ("/two-factor/verify-backup-code", TWO_FACTOR_TAG),
];

#[derive(Debug, Parser)]
#[command(
    name = "generate_docs_openapi",
    about = "Generate the v1 docs OpenAPI schema from the better-auth-rs runtime"
)]
struct Cli {
    #[arg(long, default_value = "docs/better-auth.json")]
    output: PathBuf,

    #[arg(long)]
    check: bool,
}

#[tokio::main]
async fn main() -> Result<(), DynError> {
    let cli = Cli::parse();
    let mut spec = generate_docs_openapi().await?;
    spec.sort_all_objects();
    let json = serde_json::to_string_pretty(&spec)?;

    if cli.check {
        let existing: Value = serde_json::from_str(&fs::read_to_string(&cli.output)?)?;
        if existing != spec {
            return Err(format!(
                "generated docs OpenAPI differs from {}. Re-run `cargo run --bin generate_docs_openapi --features seaorm2`.",
                cli.output.display()
            )
            .into());
        }
        eprintln!("[ok] docs OpenAPI is up to date: {}", cli.output.display());
        return Ok(());
    }

    if let Some(parent) = cli.output.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(&cli.output, json)?;
    eprintln!("[ok] wrote docs OpenAPI: {}", cli.output.display());
    Ok(())
}

async fn generate_docs_openapi() -> Result<Value, DynError> {
    let auth = build_docs_auth().await?;
    let mut spec = auth.openapi_spec()?.to_value()?;
    rewrite_for_docs(&mut spec)?;
    Ok(spec)
}

async fn build_docs_auth() -> Result<BetterAuth<BundledSchema>, DynError> {
    let config = AuthConfig::new("docs-openapi-generation-secret-key-32c")
        .base_url("http://localhost:3000")
        .password_min_length(8);

    let database = Database::connect("sqlite::memory:").await?;
    run_migrations(&database).await?;
    let store = SeaOrmStore::<BundledSchema>::new(config.clone(), database);

    let auth = AuthBuilder::<BundledSchema>::new(config)
        .store(store)
        .plugin(
            EmailPasswordPlugin::new()
                .enable_signup(true)
                .username(true),
        )
        .plugin(SessionManagementPlugin::new())
        .plugin(PasswordManagementPlugin::new())
        .plugin(AccountManagementPlugin::new())
        .plugin(EmailVerificationPlugin::new())
        .plugin(
            UserManagementPlugin::new()
                .change_email_enabled(true)
                .delete_user_enabled(true),
        )
        .plugin(OAuthPlugin::new())
        .plugin(DeviceAuthorizationPlugin::new())
        .plugin(ApiKeyPlugin::builder().build())
        .plugin(TwoFactorPlugin::new())
        .plugin(OrganizationPlugin::with_config(
            better_auth::plugins::organization::OrganizationConfig {
                teams: better_auth::plugins::organization::OrganizationTeamsConfig {
                    enabled: true,
                    ..Default::default()
                },
                dynamic_access_control: true,
                ac: Some(
                    [
                        ("organization", vec!["update", "delete"]),
                        ("member", vec!["create", "update", "delete"]),
                        ("invitation", vec!["create", "cancel"]),
                        ("team", vec!["create", "update", "delete"]),
                        ("ac", vec!["create", "read", "update", "delete"]),
                    ]
                    .into_iter()
                    .map(|(resource, actions)| {
                        (
                            resource.to_owned(),
                            actions.into_iter().map(str::to_owned).collect(),
                        )
                    })
                    .collect(),
                ),
                ..Default::default()
            },
        ))
        .plugin(
            PasskeyPlugin::new()
                .rp_id("localhost")
                .rp_name("Better Auth RS")
                .origin("http://localhost:3000"),
        )
        .plugin(AdminPlugin::new())
        .build()
        .await?;

    Ok(auth)
}

fn rewrite_for_docs(spec: &mut Value) -> Result<(), DynError> {
    let root = spec
        .as_object_mut()
        .ok_or_else(|| "OpenAPI spec must be a JSON object".to_string())?;

    let info = root
        .get_mut("info")
        .and_then(Value::as_object_mut)
        .ok_or_else(|| "OpenAPI spec must contain an info object".to_string())?;
    _ = info.insert("title".to_string(), Value::String(DOCS_TITLE.to_string()));
    _ = info.insert(
        "description".to_string(),
        Value::String(DOCS_DESCRIPTION.to_string()),
    );

    let paths = root
        .get_mut("paths")
        .and_then(Value::as_object_mut)
        .ok_or_else(|| "OpenAPI spec must contain a paths object".to_string())?;

    let mut filtered_paths = serde_json::Map::new();
    let mut missing_paths = Vec::new();

    for (path, tag) in V1_DOCS_PATHS {
        let Some(path_item) = paths.get(*path) else {
            missing_paths.push(*path);
            continue;
        };

        let mut path_item = path_item.clone();
        retag_path_item(&mut path_item, tag)?;
        _ = filtered_paths.insert((*path).to_string(), path_item);
    }

    if !missing_paths.is_empty() {
        return Err(format!(
            "Rust docs OpenAPI profile is missing v1 routes: {}",
            missing_paths.join(", ")
        )
        .into());
    }

    _ = root.insert("paths".to_string(), Value::Object(filtered_paths));
    _ = root.insert(
        "tags".to_string(),
        Value::Array(
            V1_DOCS_TAGS
                .iter()
                .map(|(name, title, description)| {
                    serde_json::json!({
                        "name": name,
                        "x-displayName": title,
                        "description": description,
                    })
                })
                .collect(),
        ),
    );
    Ok(())
}

fn retag_path_item(path_item: &mut Value, tag: &str) -> Result<(), DynError> {
    let operations = path_item
        .as_object_mut()
        .ok_or_else(|| "path item must be an object".to_string())?;

    for operation in operations.values_mut() {
        let operation = operation
            .as_object_mut()
            .ok_or_else(|| "operation must be an object".to_string())?;
        _ = operation.insert(
            "tags".to_string(),
            Value::Array(vec![Value::String(tag.to_string())]),
        );
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn v1_route_groups_use_supported_doc_tags() {
        for (_, tag) in V1_DOCS_PATHS {
            assert!(V1_DOCS_TAGS.iter().any(|(name, _, _)| name == tag));
        }
    }

    #[test]
    fn v1_route_groups_include_callback_and_username_surface() {
        assert!(V1_DOCS_PATHS.contains(&("/callback/{id}", DEFAULT_TAG)));
        assert!(V1_DOCS_PATHS.contains(&("/sign-in/username", USERNAME_TAG)));
        assert!(V1_DOCS_PATHS.contains(&("/is-username-available", USERNAME_TAG)));
    }

    #[test]
    fn v1_route_groups_exclude_server_only_backup_code_route() {
        assert!(
            !V1_DOCS_PATHS
                .iter()
                .any(|(path, _)| *path == "/two-factor/view-backup-codes")
        );
    }
}