better-auth-api 0.10.0

Plugin implementations for better-auth
Documentation
use std::sync::Arc;

use better_auth_api::DeviceAuthorizationPlugin;
use better_auth_core::adapters::{MemoryDatabaseAdapter, UserOps, VerificationOps};
use better_auth_core::{
    AuthConfig, AuthContext, AuthPlugin, AuthRequest, CreateUser, HttpMethod, Session, User,
};
use serde_json::{Value, json};

const TEST_SECRET: &str = "test-secret-key-that-is-at-least-32-characters-long";

fn create_test_context() -> AuthContext<MemoryDatabaseAdapter> {
    let config = Arc::new(AuthConfig::new(TEST_SECRET));
    let database = Arc::new(MemoryDatabaseAdapter::new());
    AuthContext::new(config, database)
}

async fn create_authenticated_user(
    ctx: &AuthContext<MemoryDatabaseAdapter>,
    email: &str,
) -> (User, Session) {
    let user = ctx
        .database
        .create_user(CreateUser::new().with_email(email).with_name("Device User"))
        .await
        .unwrap();

    let session = ctx
        .session_manager()
        .create_session(&user, None, None)
        .await
        .unwrap();

    (user, session)
}

fn create_json_request(
    method: HttpMethod,
    path: &str,
    token: Option<&str>,
    body: Value,
) -> AuthRequest {
    let mut req = AuthRequest::new(method, path);
    req.body = Some(serde_json::to_vec(&body).unwrap());
    req.headers
        .insert("content-type".to_string(), "application/json".to_string());

    if let Some(token) = token {
        req.headers
            .insert("authorization".to_string(), format!("Bearer {token}"));
    }

    req
}

fn json_body(response: &better_auth_core::AuthResponse) -> Value {
    serde_json::from_slice(&response.body).unwrap()
}

fn device_code_identifier(device_code: &str) -> String {
    format!("device_code:{device_code}")
}

fn user_code_identifier(user_code: &str) -> String {
    format!("user_code:{user_code}")
}

async fn issue_device_code(
    plugin: &DeviceAuthorizationPlugin,
    ctx: &AuthContext<MemoryDatabaseAdapter>,
) -> (String, String) {
    let req = create_json_request(
        HttpMethod::Post,
        "/device/code",
        None,
        json!({
            "client_id": "living-room-tv",
            "scope": "openid profile",
        }),
    );

    let response = plugin
        .on_request(&req, ctx)
        .await
        .unwrap()
        .expect("device code response");

    assert_eq!(response.status, 200);
    let body = json_body(&response);
    (
        body["device_code"].as_str().unwrap().to_string(),
        body["user_code"].as_str().unwrap().to_string(),
    )
}

#[tokio::test]
async fn device_code_endpoint_returns_expected_payload_and_persists_codes() {
    let ctx = create_test_context();
    let plugin = DeviceAuthorizationPlugin::new()
        .enabled(true)
        .verification_uri("/activate")
        .interval(7)
        .expires_in(300);

    let req = create_json_request(
        HttpMethod::Post,
        "/device/code",
        None,
        json!({
            "client_id": "living-room-tv",
            "scope": "openid profile",
        }),
    );

    let response = plugin.on_request(&req, &ctx).await.unwrap().unwrap();
    assert_eq!(response.status, 200);

    let body = json_body(&response);
    let device_code = body["device_code"].as_str().unwrap();
    let user_code = body["user_code"].as_str().unwrap();

    assert_eq!(body["verification_uri"], "/activate");
    assert_eq!(body["expires_in"], 300);
    assert_eq!(body["interval"], 7);
    assert_eq!(device_code.len(), 40);
    assert_eq!(user_code.len(), 8);
    assert!(
        user_code
            .chars()
            .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit())
    );

    assert!(
        ctx.database
            .get_verification_by_identifier(&device_code_identifier(device_code))
            .await
            .unwrap()
            .is_some()
    );
    assert!(
        ctx.database
            .get_verification_by_identifier(&user_code_identifier(user_code))
            .await
            .unwrap()
            .is_some()
    );
}

#[tokio::test]
async fn token_endpoint_returns_authorization_pending_until_approved() {
    let ctx = create_test_context();
    let plugin = DeviceAuthorizationPlugin::new().enabled(true).interval(0);
    let (device_code, _) = issue_device_code(&plugin, &ctx).await;

    let req = create_json_request(
        HttpMethod::Post,
        "/device/token",
        None,
        json!({ "device_code": device_code }),
    );

    let err = plugin.on_request(&req, &ctx).await.unwrap_err();
    assert_eq!(err.status_code(), 400);
    assert_eq!(err.to_string(), "authorization_pending");
}

#[tokio::test]
async fn approve_flow_exchanges_device_code_for_a_single_session_token() {
    let ctx = create_test_context();
    let plugin = DeviceAuthorizationPlugin::new().enabled(true).interval(0);
    let (user, session) = create_authenticated_user(&ctx, "approve@example.com").await;
    let (device_code, user_code) = issue_device_code(&plugin, &ctx).await;

    let approve_req = create_json_request(
        HttpMethod::Post,
        "/device/approve",
        Some(&session.token),
        json!({ "userCode": user_code.clone() }),
    );

    let approve_response = plugin
        .on_request(&approve_req, &ctx)
        .await
        .unwrap()
        .unwrap();
    assert_eq!(approve_response.status, 200);
    assert_eq!(json_body(&approve_response)["status"], true);
    assert!(
        ctx.database
            .get_verification_by_identifier(&user_code_identifier(&user_code))
            .await
            .unwrap()
            .is_none()
    );

    let token_req = create_json_request(
        HttpMethod::Post,
        "/device/token",
        None,
        json!({ "device_code": device_code.clone() }),
    );

    let token_response = plugin.on_request(&token_req, &ctx).await.unwrap().unwrap();
    assert_eq!(token_response.status, 200);

    let access_token = json_body(&token_response)["access_token"]
        .as_str()
        .unwrap()
        .to_string();
    let exchanged_session = ctx
        .session_manager()
        .get_session(&access_token)
        .await
        .unwrap()
        .expect("session created for approved device");

    assert_eq!(exchanged_session.user_id, user.id);
    assert!(
        ctx.database
            .get_verification_by_identifier(&device_code_identifier(&device_code))
            .await
            .unwrap()
            .is_none()
    );

    let second_token_req = create_json_request(
        HttpMethod::Post,
        "/device/token",
        None,
        json!({ "device_code": device_code }),
    );
    let err = plugin
        .on_request(&second_token_req, &ctx)
        .await
        .unwrap_err();
    assert_eq!(err.to_string(), "invalid_grant");
}

#[tokio::test]
async fn deny_flow_requires_authentication_and_blocks_token_exchange() {
    let ctx = create_test_context();
    let plugin = DeviceAuthorizationPlugin::new().enabled(true).interval(0);
    let (_, session) = create_authenticated_user(&ctx, "deny@example.com").await;
    let (device_code, user_code) = issue_device_code(&plugin, &ctx).await;

    let unauthenticated_deny_req = create_json_request(
        HttpMethod::Post,
        "/device/deny",
        None,
        json!({ "userCode": user_code.clone() }),
    );
    let err = plugin
        .on_request(&unauthenticated_deny_req, &ctx)
        .await
        .unwrap_err();
    assert_eq!(err.status_code(), 401);

    let deny_req = create_json_request(
        HttpMethod::Post,
        "/device/deny",
        Some(&session.token),
        json!({ "userCode": user_code }),
    );

    let deny_response = plugin.on_request(&deny_req, &ctx).await.unwrap().unwrap();
    assert_eq!(deny_response.status, 200);
    assert_eq!(json_body(&deny_response)["status"], true);

    let token_req = create_json_request(
        HttpMethod::Post,
        "/device/token",
        None,
        json!({ "device_code": device_code }),
    );
    let err = plugin.on_request(&token_req, &ctx).await.unwrap_err();
    assert_eq!(err.status_code(), 400);
    assert_eq!(err.to_string(), "access_denied");
}

#[tokio::test]
async fn disabled_plugin_returns_not_found_for_device_routes() {
    let ctx = create_test_context();
    let plugin = DeviceAuthorizationPlugin::new();

    let req = create_json_request(
        HttpMethod::Post,
        "/device/code",
        None,
        json!({ "client_id": "living-room-tv" }),
    );

    let err = plugin.on_request(&req, &ctx).await.unwrap_err();
    assert_eq!(err.status_code(), 404);
    assert_eq!(err.to_string(), "Not found");
}