use super::*;
#[tokio::test]
async fn scoped_verification_uses_the_configuration_hashing_and_rejects_other_configs() {
let plugin = ApiKeyPlugin::builder().build().configuration(ApiKeyConfig {
config_id: "machines".to_owned(),
disable_key_hashing: true,
..Default::default()
});
let (ctx, _, session) = create_test_context_with_user().await;
let (id, key) = create_key_and_get_raw(
&plugin,
&ctx,
&session.token,
serde_json::json!({ "configId": "machines", "name": "machine" }),
)
.await;
let scoped = VerifyApiKey {
key: &key,
config_id: Some("machines"),
permissions: None,
};
let verified = plugin.verify_api_key(&scoped, &ctx).await.unwrap();
assert_eq!(verified.id, id);
assert_eq!(verified.config_id, "machines");
let serialized = serde_json::to_value(verified).unwrap();
assert!(serialized.get("key").is_none());
assert!(serialized.get("keyHash").is_none());
for config_id in [None, Some("default"), Some("unknown")] {
let error = plugin
.verify_api_key(
&VerifyApiKey {
config_id,
..scoped
},
&ctx,
)
.await
.unwrap_err();
assert!(matches!(
error,
ApiKeyVerificationError::Validation(ApiKeyValidationError {
code: ApiKeyErrorCode::InvalidApiKey,
..
})
));
}
assert_eq!(
ctx.database
.get_api_key_by_id(&id)
.await
.unwrap()
.unwrap()
.request_count,
Some(1.0)
);
}
#[tokio::test]
async fn unscoped_verification_uses_the_issuing_configuration_limits() {
let plugin = ApiKeyPlugin::builder().build().configuration(ApiKeyConfig {
config_id: "machines".to_owned(),
rate_limit: RateLimitDefaults {
enabled: false,
..Default::default()
},
..Default::default()
});
let (ctx, _, session) = create_test_context_with_user().await;
let (id, key) = create_key_and_get_raw(
&plugin,
&ctx,
&session.token,
serde_json::json!({ "configId": "machines" }),
)
.await;
ctx.database
.update_api_key(
&id.clone().into(),
UpdateApiKey {
rate_limit_enabled: Some(true),
rate_limit_max: Some(1.0),
..Default::default()
},
)
.await
.unwrap();
let input = VerifyApiKey {
key: &key,
config_id: None,
permissions: None,
};
for _ in 0..3 {
assert_eq!(plugin.verify_api_key(&input, &ctx).await.unwrap().id, id);
}
let error = plugin
.verify_api_key(
&VerifyApiKey {
config_id: Some("default"),
..input
},
&ctx,
)
.await
.unwrap_err();
assert!(matches!(
error,
ApiKeyVerificationError::Validation(ApiKeyValidationError {
code: ApiKeyErrorCode::InvalidApiKey,
..
})
));
assert_eq!(
ctx.database
.get_api_key_by_id(&id)
.await
.unwrap()
.unwrap()
.request_count,
Some(0.0)
);
}
#[tokio::test]
async fn permissions_failure_preserves_usage_and_rate_limit_reports_retry_time() {
let plugin = ApiKeyPlugin::builder().build();
let (ctx, _, session) = create_test_context_with_user().await;
let (id, key) = create_key_with_server_fields(
&plugin,
&ctx,
&session.token,
serde_json::json!({}),
UpdateApiKey {
remaining: Some(3.0),
permissions: Some(serde_json::json!({ "nodes": ["read"] }).to_string()),
rate_limit_max: Some(1.0),
rate_limit_time_window: Some(60_000.0),
..Default::default()
},
)
.await;
let denied = serde_json::json!({ "nodes": ["delete"] });
let allowed = serde_json::json!({ "nodes": ["read"] });
let input = VerifyApiKey {
key: &key,
config_id: None,
permissions: Some(&denied),
};
let error = plugin.verify_api_key(&input, &ctx).await.unwrap_err();
assert!(matches!(
error,
ApiKeyVerificationError::Validation(ApiKeyValidationError {
code: ApiKeyErrorCode::KeyNotFound,
..
})
));
assert_eq!(
ctx.database
.get_api_key_by_id(&id)
.await
.unwrap()
.unwrap()
.remaining,
Some(3.0)
);
let input = VerifyApiKey {
permissions: Some(&allowed),
..input
};
assert_eq!(
plugin.verify_api_key(&input, &ctx).await.unwrap().remaining,
Some(2.0)
);
let error = plugin.verify_api_key(&input, &ctx).await.unwrap_err();
let ApiKeyVerificationError::Validation(error) = error else {
panic!("Expected rate limit rejection")
};
assert_eq!(error.code, ApiKeyErrorCode::RateLimited);
let retry = error.details.unwrap().try_again_in;
assert!(retry > 0.0 && retry <= 60_000.0);
assert_eq!(
ctx.database
.get_api_key_by_id(&id)
.await
.unwrap()
.unwrap()
.remaining,
Some(1.0)
);
}
#[tokio::test]
async fn session_header_selects_a_named_config_without_a_default() {
let plugin = ApiKeyPlugin::builder()
.config_id("machines".to_owned())
.api_key_headers(vec![
"x-machine-key".to_owned(),
"x-alternate-key".to_owned(),
])
.enable_session_for_api_keys(true)
.build();
let (ctx, user, session) = create_test_context_with_user().await;
let (_, key) = create_key_and_get_raw(
&plugin,
&ctx,
&session.token,
serde_json::json!({ "configId": "machines" }),
)
.await;
let mut request = create_auth_request(HttpMethod::Get, "/get-session", None, None, None);
request
.headers
.insert("x-alternate-key".to_owned(), key.clone());
let result = plugin
.before_request(&request, &ctx)
.await
.unwrap()
.unwrap();
let BeforeRequestAction::Respond(response) = result else {
panic!("Expected virtual session")
};
assert_eq!(response.status, 200);
let body = json_body(&response);
assert_eq!(body["session"]["token"], key);
assert_eq!(body["session"]["userId"], user.id.typed().unwrap().as_str());
assert_eq!(body["user"]["emailVerified"], false);
assert!(body["session"]["createdAt"].is_string());
assert!(body["session"]["expiresAt"].is_string());
}
#[tokio::test]
async fn session_header_cannot_authenticate_a_different_configuration() {
let plugin = ApiKeyPlugin::builder()
.enable_session_for_api_keys(true)
.build()
.configuration(ApiKeyConfig {
config_id: "machines".to_owned(),
api_key_headers: vec!["x-machine-key".to_owned()],
enable_session_for_api_keys: true,
..Default::default()
});
let (ctx, _, session) = create_test_context_with_user().await;
let (id, key) = create_key_and_get_raw(
&plugin,
&ctx,
&session.token,
serde_json::json!({ "configId": "machines" }),
)
.await;
let mut request = create_auth_request(HttpMethod::Get, "/get-session", None, None, None);
request.headers.insert("x-api-key".to_owned(), key);
let result = plugin
.before_request(&request, &ctx)
.await
.unwrap()
.unwrap();
let BeforeRequestAction::Respond(response) = result else {
panic!("Expected credential rejection")
};
assert_eq!(response.status, 401);
assert_eq!(json_body(&response)["code"], "INVALID_API_KEY");
assert_eq!(
ctx.database
.get_api_key_by_id(&id)
.await
.unwrap()
.unwrap()
.request_count,
Some(0.0)
);
}
#[tokio::test]
async fn organization_key_verifies_without_emulating_a_user_session() {
let plugin = ApiKeyPlugin::builder()
.references(ApiKeyReferences::Organization)
.enable_session_for_api_keys(true)
.build();
let (ctx, user, _) = create_test_context_with_user().await;
let (key, key_hash) = ApiKeyPlugin::generate_key(&ApiKeyConfig::default(), None);
let start = better_auth_core::ApiKeyStart::prefix(
&key,
ApiKeyConfig::default().starting_characters_length,
);
ctx.database
.create_api_key(better_auth_core::CreateApiKey {
additional_fields: Default::default(),
reference_id: user.id.typed().unwrap().clone(),
config_id: "default".to_owned(),
key_hash,
start: Some(start),
enabled: true,
name: None.into(),
prefix: None,
expires_at: None,
remaining: None,
rate_limit_enabled: false,
rate_limit_time_window: None,
rate_limit_max: None,
refill_interval: None,
refill_amount: None,
permissions: None,
metadata: None,
})
.await
.unwrap();
plugin
.verify_api_key(
&VerifyApiKey {
key: &key,
config_id: None,
permissions: None,
},
&ctx,
)
.await
.unwrap();
let mut request = create_auth_request(HttpMethod::Get, "/get-session", None, None, None);
request.headers.insert("x-api-key".to_owned(), key);
let result = plugin
.before_request(&request, &ctx)
.await
.unwrap()
.unwrap();
let BeforeRequestAction::Respond(response) = result else {
panic!("Expected organization rejection")
};
assert_eq!(response.status, 401);
assert_eq!(
json_body(&response)["code"],
"INVALID_REFERENCE_ID_FROM_API_KEY"
);
}
#[tokio::test]
async fn api_key_initialization_rejects_ambiguous_configurations() {
let (ctx, _, _) = create_test_context_with_user().await;
for config_id in ["", "default"] {
let plugin = ApiKeyPlugin::builder().build().configuration(ApiKeyConfig {
config_id: config_id.to_owned(),
..Default::default()
});
let mut init =
better_auth_core::AuthInitContext::new(ctx.config.clone(), ctx.database.clone());
assert!(matches!(
plugin.on_init(&mut init).await,
Err(AuthError::Config(_))
));
}
}
#[tokio::test]
async fn verification_preserves_database_failures() {
let connection = better_auth_seaorm::Database::connect("sqlite::memory:")
.await
.unwrap();
connection.clone().close().await.unwrap();
let config = Arc::new(crate::plugins::test_helpers::create_test_config());
let database = Arc::new(better_auth_seaorm::SeaOrmStore::<TestSchema>::new(
config.clone(),
connection,
));
let ctx = AuthContext::new(config, database);
let plugin = ApiKeyPlugin::builder().build();
let error = plugin
.verify_api_key(
&VerifyApiKey {
key: "not-looked-up",
config_id: None,
permissions: None,
},
&ctx,
)
.await
.unwrap_err();
assert!(matches!(
error,
ApiKeyVerificationError::Internal(AuthError::Database(_))
));
}
#[tokio::test]
async fn verified_session_authenticates_a_protected_plugin_route_without_a_database_session() {
let plugin = ApiKeyPlugin::builder()
.enable_session_for_api_keys(true)
.build();
let (ctx, user, session) = create_test_context_with_user().await;
let (id, key) =
create_key_and_get_raw(&plugin, &ctx, &session.token, serde_json::json!({})).await;
let mut request = create_auth_request(HttpMethod::Get, "/api-key/list", None, None, None);
request.headers.insert("x-api-key".to_owned(), key.clone());
let action = plugin
.before_request(&request, &ctx)
.await
.unwrap()
.unwrap();
let BeforeRequestAction::InjectSession { session } = action else {
panic!("Expected virtual session")
};
assert_eq!(session.token, key);
request.set_virtual_session(*session);
let response = plugin.on_request(&request, &ctx).await.unwrap().unwrap();
assert_eq!(response.status, 200);
assert_eq!(json_body(&response)["apiKeys"][0]["id"], id);
assert_eq!(
ctx.database
.get_user_sessions(user.id.typed().unwrap())
.await
.unwrap()
.len(),
1
);
}