2

I've built a simple AspNetCore 2.2 API that uses IdentityServer 4 to handle OAuth. It's working fine but I'd now like to add integration tests and recently discovered this. I used it to build some tests which all worked fine - as long as I didn't have the [Authorize] attribute on my controllers - but obviously that attribute needs to be there.

I came across this stackoverflow question and from the answers given there I tried to put a test together but I'm still getting an Unauthorized response when I try to run tests.

Please note: I really don't know what details I should be using when I'm creating the client.

  • What should the allowed scopes be? (Should they match the real scopes)

Also when building the IdentityServerWebHostBuilder

  • What should I pass to .AddApiResources? (Maybe a dumb question but does it matter)

If anyone can guide me it would be greatly appreciated.

Here is my test:

[Fact]
public async Task Attempt_To_Test_InMemory_IdentityServer()
{
    // Create a client
        var clientConfiguration = new ClientConfiguration("MyClient", "MySecret");

        var client = new Client
        {
            ClientId = clientConfiguration.Id,
            ClientSecrets = new List<Secret>
            {
                new Secret(clientConfiguration.Secret.Sha256())
            },
            AllowedScopes = new[] { "api1" },
            AllowedGrantTypes = new[] { GrantType.ClientCredentials },
            AccessTokenType = AccessTokenType.Jwt,
            AllowOfflineAccess = true
        };

        var webHostBuilder = new IdentityServerWebHostBuilder()
            .AddClients(client)
            .AddApiResources(new ApiResource("api1", "api1name"))
            .CreateWebHostBuilder();

        var identityServerProxy = new IdentityServerProxy(webHostBuilder);
        var tokenResponse = await identityServerProxy.GetClientAccessTokenAsync(clientConfiguration, "api1");

        // *****
        // Note: creating an IdentityServerProxy above in order to get an access token
        // causes the next line to throw an exception stating: WebHostBuilder allows creation only of a single instance of WebHost
        // *****

        // Create an auth server from the IdentityServerWebHostBuilder 
        HttpMessageHandler handler;
        try
        {
            var fakeAuthServer = new TestServer(webHostBuilder);
            handler = fakeAuthServer.CreateHandler();
        }
        catch (Exception e)
        {
            throw;
        }

        // Create an auth server from the IdentityServerWebHostBuilder 
        HttpMessageHandler handler;
        try
        {
            var fakeAuthServer = new TestServer(webHostBuilder);
            handler = fakeAuthServer.CreateHandler();
        }
        catch (Exception e)
        {
            Console.WriteLine(e);
            throw;
        }

        // Set the BackChannelHandler of the 'production' IdentityServer to use the 
        // handler form the fakeAuthServer
        Startup.BackChannelHandler = handler;
        // Create the apiServer
        var apiServer = new TestServer(new WebHostBuilder().UseStartup<Startup>());
        var apiClient = apiServer.CreateClient();


        apiClient.SetBearerToken(tokenResponse.AccessToken);

        var user = new User
        {
            Username = "simonlomax@ekm.com",
            Password = "Password-123"
        };

        var req = new HttpRequestMessage(new HttpMethod("GET"), "/api/users/login")
        {
            Content = new StringContent(JsonConvert.SerializeObject(user), Encoding.UTF8, "application/json"),
        };

        // Act
        var response = await apiClient.SendAsync(req);

        // Assert
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);

}

My Startup class:

public class Startup
{

    public IConfiguration Configuration { get; }
    public static HttpMessageHandler BackChannelHandler { get; set; }

    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    // This method gets called by the runtime. Use this method to add services to the container.
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
        ConfigureAuth(services);    
        services.AddTransient<IPassportService, PassportService>();
        services.Configure<ApiBehaviorOptions>(options =>
        {
            options.SuppressModelStateInvalidFilter = true;
        });

    }

    protected virtual void ConfigureAuth(IServiceCollection services)
    {
        services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(options =>
            {
                options.Authority = Configuration.GetValue<string>("IdentityServerAuthority");
                options.Audience = Configuration.GetValue<string>("IdentityServerAudience");
                options.BackchannelHttpHandler = BackChannelHandler;
            });
    }


    // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        else
        {
            app.UseHsts();
        }

        app.UseAuthentication();
        app.UseHttpsRedirection();
        app.UseMvc();
        app.UseExceptionMiddleware();
    }
}
ArunPratap
  • 4,816
  • 7
  • 25
  • 43
Simon Lomax
  • 8,714
  • 8
  • 42
  • 75
  • Can you please add the code used for the actual token-request? What's the error you are receiving? – alsami Apr 09 '19 at 11:03
  • @alsami I'm getting an "unauthorized" which makes sense because I wasn't passing a bearer token so I've add some code that I thought would do that but that now causes other issues which hopefully I've explained in the comments in the updated code. – Simon Lomax Apr 09 '19 at 11:27
  • @alsami Although I can now get an access token I don't know how to wire up my `TestServer` for the API with the `IdentityServerProxy` – Simon Lomax Apr 09 '19 at 14:52
  • Can you provide the complete source-code on github? I would need to test manually to see what is not working. – alsami Apr 09 '19 at 14:56

3 Answers3

4

Edit:

The below suggestion was one problem. The original source-code failed due to an exception by trying to build WebHostBuilder twice. Secondly the configuration-file was only present in the API project, not in the test-project, thats why authority wasn't set as well.

Instead of doing this

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
   .AddJwtBearer(options =>
   {
       options.Authority = Configuration.GetValue<string>("IdentityServerAuthority");
       options.Audience = Configuration.GetValue<string>("IdentityServerAudience");
       options.BackchannelHttpHandler = BackChannelHandler;
   });

You have to do something like this:

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
   .AddIdentityServerAuthentication(options =>
   {
      options.Authority = Configuration.GetValue<string>("IdentityServerAuthority");
      options.JwtBackChannelHandler = BackChannelHandler;
    });

You can find a sample here.

Hope that helps, worked for me!

alsami
  • 8,996
  • 3
  • 25
  • 36
  • Thanks for your suggestion but I couldn't seem to get that working either. I've added a [git repo](https://github.com/simax/SuperSimpleAPI/tree/master) as you requested. It's a super simple API that uses IdentityServer for auth. The project also has an IntegrationTest project that includes the test I posted here. I would be extremely grateful if you could take a look and tell me where I'm going wrong. – Simon Lomax Apr 10 '19 at 09:24
  • 1
    got you a merge-request that fixed it: https://github.com/simax/SuperSimpleAPI/pull/1 – alsami Apr 10 '19 at 10:42
  • 2
    That's awesome. Thank you so much for your help. One thing - among many :) that was confusing me was that I didn't realise I needed to call `CreateHandler()` from `identityServerProxy.IdentityServer` I think I was looking for it directly off `identityServerProxy`. Once again, thanks so much for creating the nuget package and for your help - it's very much appreciated. – Simon Lomax Apr 10 '19 at 11:12
  • @alsami I have tried your fix, https://github.com/simax/SuperSimpleAPI and I keep getting http 302. I have applied as you described. Any idea why? – Ktt Jan 22 '20 at 10:15
  • @Ktt you could provide a repo so I can reproduce it. Out of my head I have no answer why this is happening. – alsami Jan 22 '20 at 12:06
  • @alsami I have overcome it, our IOC initialization is much more complicated than the code you provided, eventually try after try, I had to remove app.UseIdentityServer() line from TestStartup and use IdentityServerProxy and it's handler as in your example. I am thinking to write a blog post about this. I will share the link with you when I do. Your example was a big help. Thank you very much – Ktt Jan 22 '20 at 13:56
  • 1
    @Ktt glad it worked out! Yeah, I was thinking about that too. Another sample can be found here btw:https://github.com/cleancodelabs/IdentityServer4.Contrib.AspNetCore.Testing – alsami Jan 22 '20 at 13:59
  • @alsami, here what I have published, I have referenced your code in github as well. https://medium.com/@kutlu_eren/authorizing-integration-tests-using-aspnetcore-testserver-identityserver4-83b8c79ceebf – Ktt Jan 30 '20 at 07:37
  • Thanks! Gonna share it! – alsami Jan 30 '20 at 08:30
  • You might want to add a reference to the package as well :) https://www.nuget.org/packages/IdentityServer4.Contrib.AspNetCore.Testing/ – alsami Jan 30 '20 at 08:35
  • @alsami, sure will do, thanks again :) please find the reference in the blog where it lays as "You might want to check its awesome implementations here." – Ktt Jan 30 '20 at 12:54
1

A solution which doesn't affect production code:

public class TestApiWebApplicationFactory<TStartup>
    : WebApplicationFactory<TStartup> where TStartup : class
{
    private readonly HttpClient _identityServerClient;

    public TestApiWebApplicationFactory(HttpClient identityServerClient)
    {
        _identityServerClient = identityServerClient;
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        base.ConfigureWebHost(builder);

        builder.ConfigureServices(
            s =>
            {
                s.AddSingleton<IConfigureOptions<JwtBearerOptions>>(services =>
                {
                    return new TestJwtBearerOptions(_identityServerClient);
                });
            });
    }
}

and its usage is:

 _factory = new WebApplicationFactory<Startup>()
        {
            ClientOptions = {BaseAddress = new Uri("http://localhost:5000/")}
        };

        _apiFactory = new TestApiWebApplicationFactory<SampleApi.Startup>(_factory.CreateClient())
        {
            ClientOptions = {BaseAddress = new Uri("http://localhost:5001/")}
        };

The TestJwtBearerOptions just proxies requests to identityServerClient. The implementation you can find here: https://gist.github.com/ru-sh/048e155d73263912297f1de1539a2687

Shakirov Ruslan
  • 323
  • 3
  • 8
  • The link is broken: do you have the code for TestJwtBearerOptions? – aksu Jan 13 '22 at 06:00
  • 1
    No, I've deleted it accidently. But it should be pretty simple to implement. You will need to inherit JwtBearerOptions and put breakpoints into its methods to figure out which one processes HTTP messages. AFAIR, it was the BackchannelHttpHandler. – Shakirov Ruslan Jan 14 '22 at 07:52
0

If you don't want to rely on a static variable to hold the HttpHandler, I've found the following to work. I think it's a lot cleaner.

First create an object that you can instantiate before your TestHost is created. This is because you won't have the HttpHandler until after the TestHost is created, so you need to use a wrapper.

    public class TestHttpMessageHandler : DelegatingHandler
    {
        private ILogger _logger;

        public TestHttpMessageHandler(ILogger logger)
        {
            _logger = logger;
        }

        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            _logger.Information($"Sending HTTP message using TestHttpMessageHandler. Uri: '{request.RequestUri.ToString()}'");

            if (WrappedMessageHandler == null) throw new Exception("You must set WrappedMessageHandler before TestHttpMessageHandler can be used.");
            var method = typeof(HttpMessageHandler).GetMethod("SendAsync", BindingFlags.Instance | BindingFlags.NonPublic);
            var result = method.Invoke(this.WrappedMessageHandler, new object[] { request, cancellationToken });
            return await (Task<HttpResponseMessage>)result;
        }

        public HttpMessageHandler WrappedMessageHandler { get; set; }
    }

Then

var testMessageHandler = new TestHttpMessageHandler(logger);

var webHostBuilder = new WebHostBuilder()
...
                        services.PostConfigureAll<JwtBearerOptions>(options =>
                        {
                            options.Audience = "http://localhost";
                            options.Authority = "http://localhost";
                            options.BackchannelHttpHandler = testMessageHandler;
                        });
...

var server = new TestServer(webHostBuilder);
var innerHttpMessageHandler = server.CreateHandler();
testMessageHandler.WrappedMessageHandler = innerHttpMessageHandler;

Josh Mouch
  • 3,480
  • 1
  • 37
  • 34