using System.Reflection;
using Bit.Api.AdminConsole.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Xunit;
namespace Bit.Api.Test.Utilities;
public static class ControllerAuthorizationTestHelpers
{
private static readonly Type[] _httpMethodAttributes =
[
typeof(HttpGetAttribute),
typeof(HttpPostAttribute),
typeof(HttpPutAttribute),
typeof(HttpDeleteAttribute),
typeof(HttpPatchAttribute),
typeof(HttpHeadAttribute),
typeof(HttpOptionsAttribute)
];
///
/// Asserts that a controller follows the two-layer authorization pattern required by Bitwarden.
///
/// The controller type to validate.
///
/// This enforces two requirements:
///
/// -
///
/// Class-level requirement: The controller MUST have a class-level [Authorize] attribute.
///
///
/// -
///
/// Method-level requirement: Every HTTP action method MUST have either:
///
/// - Any custom AuthorizeAttribute implementation (e.g., [Authorize<TRequirement>]) for protected endpoints, OR
/// - [AllowAnonymous] for intentionally public endpoints
///
///
///
///
///
/// This ensures that every route is explicitly decorated with authorization, preventing accidental
/// exposure of endpoints. The class-level [Authorize] alone is necessary but not sufficient.
/// Note that the base [Authorize] attribute is not accepted at the method level.
///
///
///
/// Thrown when the controller is missing class-level authorization, has no HTTP methods,
/// or has HTTP methods without explicit method-level authorization.
///
///
///
/// [Fact]
/// public void AllActionMethodsHaveAuthorization()
/// {
/// ControllerAuthorizationTestHelpers.AssertAllHttpMethodsHaveAuthorization(
/// typeof(MyController));
/// }
///
///
public static void AssertAllHttpMethodsHaveAuthorization(Type controllerType)
{
var methods = controllerType.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly);
var httpActionMethods = methods
.Where(HasHttpMethodAttribute)
.ToList();
if (httpActionMethods.Count == 0)
{
Assert.Fail($"Controller {controllerType.Name} has no HTTP action methods.");
}
// REQUIRE class-level [Authorize]
var classHasAuthorization = HasAuthorizeAttribute(controllerType);
if (!classHasAuthorization)
{
Assert.Fail(
$"Controller {controllerType.Name} is missing required class-level [Authorize] attribute.\n" +
$"All controllers must have [Authorize] at the class level as a baseline security measure.");
}
// REQUIRE each method to have explicit authorization
var unauthorizedMethods = new List();
foreach (var method in httpActionMethods)
{
// Only check for custom [Authorize] (not base [Authorize])
var methodHasCustomAuthorize = HasCustomAuthorizeAttribute(method);
var methodHasAllowAnonymous = HasAllowAnonymousAttribute(method);
var methodHasNoopAuthorize = HasNoopAuthorizeAttribute(method);
// Method must have EITHER [Authorize] OR [AllowAnonymous] OR [NoopAuthorize]
var hasAuthorizationAttribute = methodHasCustomAuthorize || methodHasAllowAnonymous || methodHasNoopAuthorize;
if (!hasAuthorizationAttribute)
{
var httpAttributes = string.Join(", ",
method.GetCustomAttributes()
.Where(a => _httpMethodAttributes.Contains(a.GetType()))
.Select(a => $"[{a.GetType().Name.Replace("Attribute", "")}]"));
unauthorizedMethods.Add($"{method.Name} ({httpAttributes})");
}
}
if (unauthorizedMethods.Count != 0)
{
var methodList = string.Join("\n - ", unauthorizedMethods);
Assert.Fail(
$"Controller {controllerType.Name} has {unauthorizedMethods.Count} HTTP action method(s) without method-level authorization:\n" +
$" - {methodList}\n\n" +
$"Each HTTP action method must be explicitly decorated with:\n" +
$" - [Authorize] for protected endpoints, OR\n" +
$" - [AllowAnonymous] for intentionally public endpoints, OR\n" +
$" - [NoopAuthorize] to explicitly document that no additional authorization is required\n\n" +
$"Note: Class-level [Authorize] is required but not sufficient. Every route must be explicitly decorated.");
}
}
private static bool HasHttpMethodAttribute(MethodInfo method)
{
return method.GetCustomAttributes()
.Any(attr => _httpMethodAttributes.Contains(attr.GetType()));
}
///
/// Checks if a type or method has any [Authorize] attribute (including subclasses).
/// Used for class-level checks.
///
private static bool HasAuthorizeAttribute(MemberInfo member)
{
return member.GetCustomAttributes()
.Any(attr => attr.GetType().IsAssignableTo(typeof(AuthorizeAttribute)));
}
///
/// Checks if a method has a custom (subclassed) [Authorize] attribute.
/// Does NOT match the base [Authorize] attribute.
/// Used for method-level checks.
///
///
/// We don't match the base [Authorize] attribute because we don't currently use this
/// for role-based checks on methods, so it is unlikely to indicate a
/// proper authorization check. This is based on current practice only and could be
/// changed in the future if our practice changes.
///
private static bool HasCustomAuthorizeAttribute(MethodInfo method)
{
return method.GetCustomAttributes()
.Select(attr => attr.GetType())
.Any(attrType => attrType.IsSubclassOf(typeof(AuthorizeAttribute)));
}
private static bool HasAllowAnonymousAttribute(MethodInfo method)
{
return method.GetCustomAttribute() != null;
}
private static bool HasNoopAuthorizeAttribute(MethodInfo method)
{
return method.GetCustomAttribute() != null;
}
}