use std::sync::{Arc, Mutex};
use http::{header, StatusCode};
use openauth_core::api::{core_auth_async_endpoints, AuthRouter};
use openauth_core::context::create_auth_context_with_adapter;
use openauth_core::db::{DbAdapter, DbRecord, DbValue, FindOne, MemoryAdapter, Where};
use openauth_core::error::OpenAuthError;
use openauth_core::options::{AdvancedOptions, OpenAuthOptions, TrustedOriginOptions};
use openauth_plugins::magic_link::{
default_key_hasher, magic_link, MagicLinkEmail, MagicLinkOptions, TokenStorage,
};
mod failure_redirects;
mod rate_limit;
mod support;
mod token_generation;
mod upstream_parity;
use support::{
build_router, get, json_body, options, post_json, seed_user, sender, sent_messages,
set_cookie_values, SECRET,
};
#[tokio::test]
async fn exposes_magic_link_plugin_metadata() -> Result<(), Box<dyn std::error::Error>> {
let sent = sent_messages();
let plugin = magic_link(options(sent.clone()));
assert_eq!(
openauth_plugins::magic_link::UPSTREAM_PLUGIN_ID,
"magic-link"
);
assert_eq!(plugin.id, "magic-link");
assert_eq!(plugin.version.as_deref(), Some(openauth_plugins::VERSION));
assert_eq!(plugin.endpoints.len(), 2);
assert_eq!(plugin.rate_limit.len(), 2);
Ok(())
}
#[tokio::test]
async fn sends_magic_link_with_url_and_metadata() -> Result<(), Box<dyn std::error::Error>> {
let sent = sent_messages();
let (router, _adapter) =
build_router(sent.clone(), MagicLinkOptions::new(sender(sent.clone())))?;
let response = post_json(
&router,
"/api/auth/sign-in/magic-link",
r#"{"email":"Ada@Example.COM","metadata":{"inviteId":"123"}}"#,
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(json_body(&response)?["status"], true);
let message = last_message(&sent)?;
assert_eq!(message.email, "Ada@Example.COM");
assert!(message
.url
.starts_with("http://localhost:3000/api/auth/magic-link/verify?"));
let metadata = message.metadata.as_ref().ok_or("missing metadata")?;
assert_eq!(metadata["inviteId"], "123");
Ok(())
}
#[tokio::test]
async fn verifies_magic_link_creates_session_and_sets_cookie(
) -> Result<(), Box<dyn std::error::Error>> {
let sent = sent_messages();
let (router, adapter) = build_router(sent.clone(), options(sent.clone()))?;
seed_user(&adapter, "user_1", "Ada", "ada@example.com", true).await?;
post_json(
&router,
"/api/auth/sign-in/magic-link",
r#"{"email":"ada@example.com"}"#,
)
.await?;
let token = token_from_last_message(&sent)?;
let response = get(
&router,
&format!("/api/auth/magic-link/verify?token={token}"),
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let body = json_body(&response)?;
assert!(body["token"]
.as_str()
.is_some_and(|value| !value.is_empty()));
assert_eq!(body["user"]["email"], "ada@example.com");
assert!(set_cookie_values(&response)
.iter()
.any(|cookie| cookie.starts_with("better-auth.session_token=")));
assert_eq!(adapter.len("session").await, 1);
Ok(())
}
#[tokio::test]
async fn rejects_reused_expired_and_invalid_tokens() -> Result<(), Box<dyn std::error::Error>> {
let sent = sent_messages();
let (router, adapter) = build_router(sent.clone(), options(sent.clone()))?;
seed_user(&adapter, "user_1", "Ada", "ada@example.com", true).await?;
post_json(
&router,
"/api/auth/sign-in/magic-link",
r#"{"email":"ada@example.com"}"#,
)
.await?;
let token = token_from_last_message(&sent)?;
let first = get(
&router,
&format!("/api/auth/magic-link/verify?token={token}"),
)
.await?;
assert_eq!(first.status(), StatusCode::OK);
let reused = get(
&router,
&format!("/api/auth/magic-link/verify?token={token}"),
)
.await?;
assert_redirect_error(&reused, "ATTEMPTS_EXCEEDED")?;
let invalid = get(&router, "/api/auth/magic-link/verify?token=missing").await?;
assert_redirect_error(&invalid, "INVALID_TOKEN")?;
let short_lived = MagicLinkOptions::new(sender(sent.clone())).expires_in(1);
let (router, _adapter) = build_router(sent.clone(), short_lived)?;
post_json(
&router,
"/api/auth/sign-in/magic-link",
r#"{"email":"ada@example.com"}"#,
)
.await?;
let expired_token = token_from_last_message(&sent)?;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let expired = get(
&router,
&format!("/api/auth/magic-link/verify?token={expired_token}"),
)
.await?;
assert_redirect_error(&expired, "EXPIRED_TOKEN")?;
Ok(())
}
#[tokio::test]
async fn signs_up_new_users_and_can_disable_sign_up() -> Result<(), Box<dyn std::error::Error>> {
let sent = sent_messages();
let (router, adapter) = build_router(sent.clone(), options(sent.clone()))?;
post_json(
&router,
"/api/auth/sign-in/magic-link",
r#"{"email":"new@example.com","name":"New User"}"#,
)
.await?;
let token = token_from_last_message(&sent)?;
let response = get(
&router,
&format!("/api/auth/magic-link/verify?token={token}"),
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let user = find_user(&adapter, "new@example.com")
.await?
.ok_or("missing new user")?;
assert_eq!(
user.get("name"),
Some(&DbValue::String("New User".to_owned()))
);
assert_eq!(user.get("email_verified"), Some(&DbValue::Boolean(true)));
let disabled = MagicLinkOptions::new(sender(sent.clone())).disable_sign_up(true);
let (router, _adapter) = build_router(sent.clone(), disabled)?;
post_json(
&router,
"/api/auth/sign-in/magic-link",
r#"{"email":"blocked@example.com"}"#,
)
.await?;
let token = token_from_last_message(&sent)?;
let response = get(
&router,
&format!("/api/auth/magic-link/verify?token={token}"),
)
.await?;
assert_redirect_error(&response, "new_user_signup_disabled")?;
Ok(())
}
#[tokio::test]
async fn verifies_existing_unverified_user() -> Result<(), Box<dyn std::error::Error>> {
let sent = sent_messages();
let (router, adapter) = build_router(sent.clone(), options(sent.clone()))?;
seed_user(&adapter, "user_1", "Ada", "ada@example.com", false).await?;
post_json(
&router,
"/api/auth/sign-in/magic-link",
r#"{"email":"ada@example.com"}"#,
)
.await?;
let token = token_from_last_message(&sent)?;
let response = get(
&router,
&format!("/api/auth/magic-link/verify?token={token}"),
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(json_body(&response)?["user"]["email_verified"], true);
let user = find_user(&adapter, "ada@example.com")
.await?
.ok_or("missing verified user")?;
assert_eq!(user.get("email_verified"), Some(&DbValue::Boolean(true)));
Ok(())
}
#[tokio::test]
async fn respects_allowed_attempts_and_unlimited_attempts() -> Result<(), Box<dyn std::error::Error>>
{
let sent = sent_messages();
let opts = MagicLinkOptions::new(sender(sent.clone())).allowed_attempts(3);
let (router, adapter) = build_router(sent.clone(), opts)?;
seed_user(&adapter, "user_1", "Ada", "ada@example.com", true).await?;
post_json(
&router,
"/api/auth/sign-in/magic-link",
r#"{"email":"ada@example.com"}"#,
)
.await?;
let token = token_from_last_message(&sent)?;
for _ in 0..3 {
let response = get(
&router,
&format!("/api/auth/magic-link/verify?token={token}"),
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
}
let fourth = get(
&router,
&format!("/api/auth/magic-link/verify?token={token}"),
)
.await?;
assert_redirect_error(&fourth, "ATTEMPTS_EXCEEDED")?;
let opts = MagicLinkOptions::new(sender(sent.clone())).unlimited_attempts();
let (router, adapter) = build_router(sent.clone(), opts)?;
seed_user(&adapter, "user_2", "Grace", "grace@example.com", true).await?;
post_json(
&router,
"/api/auth/sign-in/magic-link",
r#"{"email":"grace@example.com"}"#,
)
.await?;
let token = token_from_last_message(&sent)?;
for _ in 0..5 {
let response = get(
&router,
&format!("/api/auth/magic-link/verify?token={token}"),
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
}
Ok(())
}
#[tokio::test]
async fn supports_token_storage_modes() -> Result<(), Box<dyn std::error::Error>> {
let sent = sent_messages();
let hashed_opts = MagicLinkOptions::new(sender(sent.clone())).store_token(TokenStorage::Hashed);
let (router, adapter) = build_router(sent.clone(), hashed_opts)?;
post_json(
&router,
"/api/auth/sign-in/magic-link",
r#"{"email":"ada@example.com"}"#,
)
.await?;
let token = token_from_last_message(&sent)?;
let hashed = default_key_hasher(&token);
assert!(find_verification(&adapter, &hashed).await?.is_some());
let custom_opts =
MagicLinkOptions::new(sender(sent.clone())).store_token(TokenStorage::custom(|token| {
Box::pin(async move { Ok(format!("{token}:hashed")) })
}));
let (router, adapter) = build_router(sent.clone(), custom_opts)?;
post_json(
&router,
"/api/auth/sign-in/magic-link",
r#"{"email":"ada@example.com"}"#,
)
.await?;
let token = token_from_last_message(&sent)?;
assert!(find_verification(&adapter, &format!("{token}:hashed"))
.await?
.is_some());
Ok(())
}
#[tokio::test]
async fn rejects_untrusted_verify_callback_urls() -> Result<(), Box<dyn std::error::Error>> {
let sent = sent_messages();
let adapter = Arc::new(MemoryAdapter::new());
let plugin = magic_link(options(sent.clone()));
let context = create_auth_context_with_adapter(
OpenAuthOptions {
base_url: Some("http://localhost:3000".to_owned()),
trusted_origins: TrustedOriginOptions::Static(vec!["http://localhost:3000".to_owned()]),
secret: Some(SECRET.to_owned()),
advanced: AdvancedOptions {
disable_csrf_check: true,
disable_origin_check: false,
..AdvancedOptions::default()
},
plugins: vec![plugin],
..OpenAuthOptions::default()
},
adapter.clone(),
)?;
let router = AuthRouter::with_async_endpoints(
context,
Vec::new(),
core_auth_async_endpoints(adapter.clone()),
)?;
post_json(
&router,
"/api/auth/sign-in/magic-link",
r#"{"email":"ada@example.com"}"#,
)
.await?;
let token = token_from_last_message(&sent)?;
let response = get(
&router,
&format!("/api/auth/magic-link/verify?token={token}&callbackURL=http://evil.example"),
)
.await?;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(json_body(&response)?["code"], "INVALID_CALLBACK_URL");
Ok(())
}
fn last_message(
sent: &Arc<Mutex<Vec<MagicLinkEmail>>>,
) -> Result<MagicLinkEmail, Box<dyn std::error::Error>> {
sent.lock()
.map_err(|_| "sent messages lock poisoned")?
.last()
.cloned()
.ok_or_else(|| "missing sent magic link".into())
}
fn token_from_last_message(
sent: &Arc<Mutex<Vec<MagicLinkEmail>>>,
) -> Result<String, Box<dyn std::error::Error>> {
Ok(last_message(sent)?.token)
}
fn assert_redirect_error(
response: &http::Response<Vec<u8>>,
error: &str,
) -> Result<(), Box<dyn std::error::Error>> {
assert_eq!(response.status(), StatusCode::FOUND);
let location = response
.headers()
.get(header::LOCATION)
.and_then(|value| value.to_str().ok())
.ok_or("missing location header")?;
assert!(location.contains(&format!("error={error}")), "{location}");
Ok(())
}
async fn find_user(
adapter: &MemoryAdapter,
email: &str,
) -> Result<Option<DbRecord>, OpenAuthError> {
adapter
.find_one(
FindOne::new("user")
.where_clause(Where::new("email", DbValue::String(email.to_owned()))),
)
.await
}
async fn find_verification(
adapter: &MemoryAdapter,
identifier: &str,
) -> Result<Option<DbRecord>, OpenAuthError> {
adapter
.find_one(FindOne::new("verification").where_clause(Where::new(
"identifier",
DbValue::String(identifier.to_owned()),
)))
.await
}