better-auth-api 1.0.0-alpha.5

Plugin implementations for better-auth
use crate::plugins::{
    email_otp::EmailOtpPlugin, email_password::EmailPasswordPlugin, magic_link::MagicLinkPlugin,
    password_management::PasswordManagementPlugin, test_helpers,
};
use better_auth_core::{
    AuthContext, AuthError, AuthPlugin, AuthRequest, AuthResult, CreateAccount, CreateUser,
    CreateVerification, FieldMap, HttpMethod,
    store::{
        EphemeralStore, StatelessSchema,
        database_hooks::{DatabaseHookContext, DatabaseHookUpdate, DatabaseHooks},
    },
    wire::{SessionView, UserView},
};
use chrono::{Duration, Utc};
use std::{
    collections::HashMap,
    sync::{
        Arc,
        atomic::{AtomicBool, AtomicUsize, Ordering},
    },
};

#[derive(Default)]
struct SessionHooks {
    cancel: AtomicBool,
    before: AtomicUsize,
    after: AtomicUsize,
}

#[better_auth_core::database_hooks()]
impl DatabaseHooks<StatelessSchema> for SessionHooks {
    async fn before_create_session(
        &self,
        _: &mut FieldMap,
        _: &DatabaseHookContext<'_, StatelessSchema>,
    ) -> AuthResult<DatabaseHookUpdate<FieldMap>> {
        self.before.fetch_add(1, Ordering::SeqCst);
        Ok(if self.cancel.load(Ordering::SeqCst) {
            DatabaseHookUpdate::Cancel
        } else {
            DatabaseHookUpdate::Continue
        })
    }

    async fn after_create_session(
        &self,
        _: Option<&SessionView>,
        _: &DatabaseHookContext<'_, StatelessSchema>,
    ) -> AuthResult<()> {
        self.after.fetch_add(1, Ordering::SeqCst);
        Ok(())
    }
}

async fn context(hooks: &Arc<SessionHooks>) -> AuthResult<AuthContext<StatelessSchema>> {
    let config = Arc::new(test_helpers::create_test_config());
    let store = EphemeralStore::new(config.clone()).with_hooks(vec![hooks.clone()]);
    test_helpers::initialize_test_context(config, Arc::new(store), &[]).await
}

async fn user(ctx: &AuthContext<StatelessSchema>) -> AuthResult<UserView> {
    ctx.database
        .create_user(
            CreateUser::new()
                .with_email("nullable@session.test")
                .with_name("Selected owner")
                .with_email_verified(true),
        )
        .await
}

async fn credential(ctx: &AuthContext<StatelessSchema>, user: &UserView) -> AuthResult<()> {
    let hash = better_auth_core::utils::password::hash_password(
        ctx.password_policy.hasher.as_ref(),
        "Password123!",
    )
    .await?;
    let _ = ctx
        .database
        .create_account(CreateAccount {
            user_id: user.id.clone(),
            account_id: user.id.clone(),
            provider_id: "credential".to_owned().into(),
            password: Some(hash).into(),
            ..Default::default()
        })
        .await?;
    Ok(())
}

fn assert_no_credentials(req: &AuthRequest) -> AuthResult<()> {
    assert!(req.new_session()?.is_none());
    assert!(!req.take_response_headers()?.contains_key("Set-Cookie"));
    Ok(())
}

fn assert_cancelled(hooks: &SessionHooks, existing: usize) {
    assert_eq!(hooks.before.load(Ordering::SeqCst), existing + 1);
    assert_eq!(hooks.after.load(Ordering::SeqCst), existing);
}

#[tokio::test]
async fn email_signin_null_session_keeps_credentials_and_returns_unauthorized() -> AuthResult<()> {
    let hooks = Arc::new(SessionHooks::default());
    hooks.cancel.store(true, Ordering::SeqCst);
    let ctx = context(&hooks).await?;
    let owner = user(&ctx).await?;
    credential(&ctx, &owner).await?;
    let req = test_helpers::create_auth_request_no_query(
        HttpMethod::Post,
        "/sign-in/email",
        None,
        Some(
            serde_json::json!({"email":"nullable@session.test","password":"Password123!"})
                .to_string()
                .into_bytes(),
        ),
    );
    let result = EmailPasswordPlugin::new().on_request(&req, &ctx).await;
    crate::plugins::test_helpers::assert_coded_api_error(
        &result.err().expect("expected builtin API error"),
        "UNAUTHORIZED",
        401,
        "FAILED_TO_CREATE_SESSION",
        "Failed to create session",
    );
    assert!(
        ctx.database
            .get_user_sessions(owner.id.typed()?)
            .await?
            .is_empty()
    );
    assert_eq!(
        ctx.database
            .get_user_accounts(owner.id.typed()?)
            .await?
            .len(),
        1
    );
    assert_cancelled(&hooks, 0);
    assert_no_credentials(&req)
}

#[tokio::test]
async fn password_replacement_null_session_keeps_password_change_and_revocation() -> AuthResult<()>
{
    let hooks = Arc::new(SessionHooks::default());
    let ctx = context(&hooks).await?;
    let owner = user(&ctx).await?;
    credential(&ctx, &owner).await?;
    let session = ctx
        .session_manager()
        .create_session(&owner, None, None)
        .await?;
    hooks.cancel.store(true, Ordering::SeqCst);
    let req = test_helpers::create_auth_request_no_query(HttpMethod::Post, "/change-password", Some(session.token.typed()?),
        Some(serde_json::json!({"currentPassword":"Password123!","newPassword":"Replacement123!","revokeOtherSessions":true}).to_string().into_bytes()));
    let result = PasswordManagementPlugin::new().on_request(&req, &ctx).await;
    crate::plugins::test_helpers::assert_coded_api_error(
        &result.err().expect("expected builtin API error"),
        "INTERNAL_SERVER_ERROR",
        500,
        "FAILED_TO_GET_SESSION",
        "Failed to get session",
    );
    assert!(
        ctx.database
            .get_user_sessions(owner.id.typed()?)
            .await?
            .is_empty()
    );
    let accounts = ctx.database.get_user_accounts(owner.id.typed()?).await?;
    assert_eq!(accounts.len(), 1);
    let account = accounts
        .first()
        .ok_or_else(|| AuthError::internal("Expected persisted account"))?;
    let password = account
        .password
        .typed()?
        .as_deref()
        .ok_or_else(|| AuthError::internal("Expected persisted password"))?;
    better_auth_core::utils::password::verify_password(
        ctx.password_policy.hasher.as_ref(),
        "Replacement123!",
        password,
    )
    .await?;
    assert_cancelled(&hooks, 1);
    assert_no_credentials(&req)
}

#[tokio::test]
async fn email_otp_null_session_consumes_proof_and_keeps_created_user() -> AuthResult<()> {
    let hooks = Arc::new(SessionHooks::default());
    hooks.cancel.store(true, Ordering::SeqCst);
    let ctx = context(&hooks).await?;
    let identifier = "sign-in-otp-nullable@session.test";
    let _ = ctx
        .database
        .create_verification(CreateVerification {
            identifier: identifier.to_owned().into(),
            value: "234567:0".to_owned().into(),
            expires_at: (Utc::now() + Duration::minutes(5)).into(),
            ..Default::default()
        })
        .await?;
    let req = test_helpers::create_auth_request_no_query(HttpMethod::Post, "/sign-in/email-otp", None,
        Some(serde_json::json!({"email":"nullable@session.test","otp":"234567","name":"Created owner"}).to_string().into_bytes()));
    let result = EmailOtpPlugin::new().on_request(&req, &ctx).await;
    assert!(
        matches!(result, Err(AuthError::Internal(message)) if message == "Cannot read properties of null (reading 'token')")
    );
    assert!(
        ctx.database
            .get_verification_by_identifier(identifier)
            .await?
            .is_none()
    );
    let owner = ctx
        .database
        .get_user_by_email("nullable@session.test")
        .await?
        .ok_or_else(|| AuthError::internal("Expected committed user"))?;
    assert!(owner.email_verified.is_truthy()?);
    assert_eq!(owner.name.typed()?.as_deref(), Some("Created owner"));
    assert!(
        ctx.database
            .get_user_sessions(owner.id.typed()?)
            .await?
            .is_empty()
    );
    assert_cancelled(&hooks, 0);
    assert_no_credentials(&req)
}

#[tokio::test]
async fn magic_link_null_session_redirects_after_consuming_proof() -> AuthResult<()> {
    let hooks = Arc::new(SessionHooks::default());
    hooks.cancel.store(true, Ordering::SeqCst);
    let ctx = context(&hooks).await?;
    let owner = user(&ctx).await?;
    let _ = ctx
        .database
        .create_verification(CreateVerification {
            identifier: "nullable-magic-token".to_owned().into(),
            value: serde_json::json!({"email":"nullable@session.test","name":"Selected owner"})
                .to_string()
                .into(),
            expires_at: (Utc::now() + Duration::minutes(5)).into(),
            ..Default::default()
        })
        .await?;
    let req = test_helpers::create_auth_request(
        HttpMethod::Get,
        "/magic-link/verify",
        None,
        None,
        HashMap::from([("token".to_owned(), "nullable-magic-token".to_owned())]),
    );
    let response = MagicLinkPlugin::new()
        .on_request(&req, &ctx)
        .await?
        .ok_or_else(|| AuthError::internal("Expected magic-link response"))?;
    assert_eq!(response.status, 302);
    assert_eq!(
        response.headers.get("Location").map(String::as_str),
        Some("http://localhost:3000/?error=failed_to_create_session")
    );
    assert!(
        ctx.database
            .get_verification_by_identifier("nullable-magic-token")
            .await?
            .is_none()
    );
    assert!(
        ctx.database
            .get_user_sessions(owner.id.typed()?)
            .await?
            .is_empty()
    );
    assert_cancelled(&hooks, 0);
    assert_no_credentials(&req)
}