AspNetCore:如何模拟外部身份验证/Microsoft 帐户以进行集成测试?

AspNetCore: How to mock external authentication / Microsoft account for integration tests?

我的应用程序堆栈中有一个 OpenID Connect / OAuth2 服务器 (IdP)。 IdP 允许本地和外部身份验证。

我有涵盖大多数场景的集成测试,但很难为外部身份验证场景创建端到端测试。有多个外部提供程序,但从我的应用程序角度来看,它们都在 OpenID Connect 上使用相同的工作流,只有细微差别(参数,即重定向 uri、方案名称等)。所以测试其中一个就足够了。其中之一是 Microsoft 帐户(又名 Azure AD)

集成测试基于WebApplicationFactory(对应HttpClient的内存服务器)。本地身份验证非常简单,因为整个部分都在我的应用程序域中运行,可以访问完整的源代码等。我只需创建一个对授权端点的请求,并在出现提示时 post 返回用户凭据(我仍然需要解析登录页面以检索防伪令牌,但这是可行的)

但是当涉及到外部,例如Microsoft Account,登录涉及多个步骤 AJAX 和最后的 post 超过10个参数,我无法逆向工程师。其他供应商也有同样的难度。

由于外部提供者只是黑盒,从我的 IdP 的角度来看,它只是发出一个挑战(重定向到外部授权)并在重定向后接收。有没有好的方法来模拟“中间”部分?

我的解决方案是创建一个中间件,它将模拟外部身份验证。然后 re-configure 外部身份验证方案的选项指向中间件正在处理的路径。您可能还想覆盖签名密钥(或关闭签名验证)。所以这段代码转到 WebApplicationFactory 的 ConfigureServices/ConfigureTestServices(等,取决于您的设置),以覆盖原始设置:

services.AddTransient<IStartupFilter, FakeExternalAuthenticationStartupFilter>();
services.Configure(AuthenticationSchemes.ExternalMicrosoft, (OpenIdConnectOptions options) =>
{
    options.Configuration = new OpenIdConnectConfiguration
    {
        AuthorizationEndpoint = FakeExternalAuthenticationStartupFilter.AuthorizeEndpoint,
    };

    options.TokenValidationParameters.IssuerSigningKey = FakeExternalAuthenticationStartupFilter.SecurityKey;
});

备注:WebApplicationFactory没有提供覆盖IApplicationBuilder(中间件)栈的方法,所以需要添加IStartupFilter

然后,中间件需要使用安全密钥发出一个令牌,并发出一个表单 post 返回到重定向 uri。实现此目的的通常方法是 return 简单的 HTML 页面,其中包含一个表单,该表单将在加载后自行提交。这在浏览器中运行良好,但 HttpClient 不会执行任何操作,因此测试必须解析响应并手动创建 post 请求。

虽然这是可行的,但我想省去这个额外的步骤,必须解析响应并 re-send 它,并使其成为一个步骤。困难是:

  • 无法重定向(以 GET 请求开始,应以 POST 结束,还需要表单数据)
  • OpenIdConnectHandler 在重定向(相关性和随机数)之前发出的 cookie 是恢复状态所必需的,仅在重定向 uri 路径(Set-Cookie with path=)

我的解决方案是在设置重定向 uri 的同一路径上创建处理授权 (GET) 请求的中间件,发出令牌并重写请求,以便 OpenIdConnectHandler 能够接收。这是中间件的 Invoke 方法:

public async Task Invoke(HttpContext httpContext)
{
    if (!HttpMethods.IsGet(httpContext.Request.Method) || !httpContext.Request.Path.StartsWithSegments(AuthorizeEndpoint))
    {
        await _next(httpContext);
        return;
    }

    // get and validate query parameters
    // Note: these are absolute minimal, might need to add more depending on your flow logic
    var clientId = httpContext.Request.Query["client_id"].FirstOrDefault();
    var state = httpContext.Request.Query["state"].FirstOrDefault();
    var nonce = httpContext.Request.Query["nonce"].FirstOrDefault();

    if (clientId is null || state is null || nonce is null)
    {
        httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
        return;
    }

    var token = CreateToken(clientId, state, nonce); // CreateToken implementation omitted, use same signing key as used above

    httpContext.Request.Method = HttpMethods.Post;
    httpContext.Request.QueryString = QueryString.Empty;
    httpContext.Request.ContentType = "application/x-www-form-urlencoded";
    var content = new FormUrlEncodedContent(new Dictionary<string, string>()
    {
        ["id_token"] = token,
        ["token_type"] = "Bearer",
        ["expires_in"] = "3600",
        ["state"] = state,
    });

    using var buffer = new MemoryStream();
    await content.CopyToAsync(buffer, httpContext.RequestAborted);
    buffer.Seek(offset: 0, loc: SeekOrigin.Begin);

    var oldBody = httpContext.Request.Body;
    httpContext.Request.Body = buffer;

    await _next(httpContext);

    httpContext.Request.Body = oldBody;
}