Eli Weinstock-Herman

Mocking OIDC Auth while integration testing ASP.Net

January 01, 2022 ▪ technical posts ▪ 8 min read

ASP.Net provides an option for integration testing that allows you to effectively spin up the web server and test with an HttpClient, with hooks to modify the startup as needed for testing (new data sources, etc.).

Unfortunately, when we rely on OIDC or SAML for authentication it can be difficult to code integration tests against our APIs and web pages.

Running a test that relies on OIDC Test failure, redirecting to external OIDC provider

What we'd like to be able to do is:

  1. Run tests without relying on external providers
  2. Run tests without having to manage special test accounts
  3. Not be blocked for looking like a DOS attack
  4. Have control over the user's tokens/session to test different states
  5. Easily test with different types of users (roles, access levels, permissions)
  6. No overhead added to tests, only specify details that matter for the test case
  7. Does not add weaknesses or conditional logic to the production auth code

This post provides a working example that meets all of those criteria, by intercepting OIDC authentication and allowing individual tests to specify the "authenticated" user they are operating as.

References:

The Test Application

I tried to keep the test application very minimal, so we can get to the good stuff.

  1. Basic MVC setup: Controllers/HomeController w/ 3 endpoints
    • Index, /: is an anonymous view of the Index.cshtml view, which will show login status + claims if available
    • Protected, /protected: requires authorization via our OIDC provider, then returns Index.cshtml
    • SampleEndpointWithoutExplicitAuthorization, /missingAuth: an example that will exercise the fallback authorization policy ("None Shall Pass") and return a non-existent access denied path
  2. OIDC setup:
    • Program.cs: configures the settings to be loaded from appsettings (far too many demos show hardcoded secrets, which is a disservice)
    • SecurityConfiguration.cs: extension method to utilize those settings and configure Open ID Connect and Cookies
    • Local Session: I generate a fake session id during the OIDC process so the demo test code can show cases for required claims
  3. Authorization:
    • Program.cs: configures policies for InteractiveUser and (fallback/default) NoneShallPass

Key Bits of Program.cs

// add authentication against Identity Server
// please stop hard-coding OIDC settings in demo code
builder.Services.Configure<OIDCSettings>(builder.Configuration.GetSection("OIDC"));
builder.Services.AddOIDCAuthentication();

// add authorization policies
builder.Services.AddAuthorization(options => {
    // InteractiveUser: must be auth'd and have a session id
    options.AddPolicy(Policies.InteractiveUser, policy => {
        policy.RequireAuthenticatedUser();
        policy.RequireClaim(SecurityConfiguration.LocalSessionIdClaim);
    });
    // (Default) NoneShallPass - ensures an explicit authorize is specified for all endpoints (reduce accidents)
    options.AddPolicy(Policies.NoneShallPass, policy => policy.RequireAssertion(_ => false));   
    options.DefaultPolicy = options.GetPolicy(Policies.NoneShallPass)!;
    options.FallbackPolicy = options.DefaultPolicy;
});

Content of SecurityConfiguration.cs

public static class SecurityConfiguration
{
    public const string LocalSessionIdClaim = "SessionId";
    public const string CookieScheme = "Cookies";
    public const string OIDCScheme = "oidc";

    public static void AddOIDCAuthentication(this IServiceCollection services)
    {
        JwtSecurityTokenHandler.DefaultMapInboundClaims = false;

        services.AddOptions<OpenIdConnectOptions>(OIDCScheme)
            .Configure<IOptionsMonitor<OIDCSettings>>((options, settings) =>
            {
                options.Authority = settings.CurrentValue.Authority;
                options.ClientId = settings.CurrentValue.ClientId;
                options.ClientSecret = settings.CurrentValue.ClientSecret;
            });

        services.AddAuthentication(options =>
        {
            options.DefaultScheme = CookieScheme;
            options.DefaultChallengeScheme = OIDCScheme;
        })
            .AddCookie(CookieScheme)
            .AddOpenIdConnect(OIDCScheme, options =>
            {
                options.ResponseType = "code";
                options.UsePkce = true;

                options.Scope.Clear();
                options.Scope.Add("openid");
                options.Scope.Add("profile");

                options.GetClaimsFromUserInfoEndpoint = true;
                options.SaveTokens = true;

                options.Events.OnTicketReceived = ctx => {
                    // pretend we have a more refined way to generate local sessions
                    var fakeSessionId = Random.Shared.NextInt64();
                    var identity = new ClaimsIdentity(new [] { 
                        new Claim(SecurityConfiguration.LocalSessionIdClaim, fakeSessionId.ToString())
                    });
                    ctx.Principal!.AddIdentity(identity);
                    return Task.CompletedTask;
                };
            });
    }
}

The Tests

Running tests against our endpoints

When we use the WebApplicationFactory, we're using the services and configuration from our main program, with the ability to adjust it for testing.

What we want to do is to bypass the OIDC authentication handler, while still having total control over what we want the authentication state to be during a given test.

The key is AuthenticationSchemeProvider. This is a small class that is registered in services to find the Authentication Scheme for a given Scheme Name. For instance, "oidc". Because this is registered with ASP.Net DI, we can replace it with our own version that will return a different authentication handler when it is asked for "oidc".

Intercept Scheme Provider

public class InterceptOidcAuthenticationSchemeProvider : AuthenticationSchemeProvider
{
    public const string InterceptedScheme = "InterceptedScheme";

    public InterceptOidcAuthenticationSchemeProvider(IOptions<AuthenticationOptions> options)
        : base(options)
    {
    }

    protected InterceptOidcAuthenticationSchemeProvider(IOptions<AuthenticationOptions> options, IDictionary<string, AuthenticationScheme> schemes)
        : base(options, schemes)
    {
    }

    public override Task<AuthenticationScheme?> GetSchemeAsync(string name)
    {
        // if this matches the OIDC scheme, call the auth provider for whichever fake one we setup for the client
        if (name == SecurityConfiguration.OIDCScheme)
        {
            return base.GetSchemeAsync(InterceptedScheme);
        }

        return base.GetSchemeAsync(name);
    }
}

Now, with no changes to our application, when it asks for the OIDC authentication handler, it will instead receive the "InterceptedScheme" handler. Our production application is placed at no extra risk and the only logic we exclude is the integration to our SSO provider.

To take advantage of this, I actually write two interceptors:

  • AutoFail - this interceptor and handler are registered by default when creating the WebApplicationFactory so we don't exercise the OIDC middleware ever (for instance, it making a call for a discovery document)
  • Intercept... above - this interceptor is registered with a matching handler only when we want to make a call as a specific logged in user

Here's how it plays out in the tests:

[Test]
public async Task Index_Visitor_ReturnsOk()
{
    var client = _application.CreateClient(DefaultOptions);

    var response = await client.GetAsync("/");

    Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
}

[Test]
public async Task Protected_LoggedInUser_ReturnsOk()
{
    var client = _application.CreateLoggedInClient<GeneralUser>(DefaultOptions, 12345);

    var response = await client.GetAsync("/protected");

    Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
    var body = await response.Content.ReadAsStringAsync();
    // there are better ways to do this, HTML parsing via 
    Assert.IsTrue(body.Contains("12345"));
}

CreateLoggedInClient<GeneralUser> creates an additional claim for the user (session id) and then uses a generic call to replace the authentication scheme provider with the Interception one above and the GeneralUser handler. Adding additional users only requires adding a new barebones class and a specific method for the new class and any parameters or additional claims needed to describe that type of user.

Here's the whole CustomWebApplicationFactory:

public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            // by default the OIDC auth scheme is intercepted and an auto-failing handler is returned,
            //  this ensures we don't accidentally call the discovery URL
            // CreateLoggedInClient will replace this provider with a different interceptor, last one in wins
            services.AddTransient<IAuthenticationSchemeProvider, AutoFailSchemeProvider>();
            services.AddAuthentication(AutoFailSchemeProvider.AutoFailScheme)
                .AddScheme<AutoFailOptions, AutoFail>(AutoFailSchemeProvider.AutoFailScheme, null);
        });
    }

    public HttpClient CreateLoggedInClient<T>(WebApplicationFactoryClientOptions options, int sessionId)
        where T : GeneralUser
    {
        return CreateLoggedInClient<T>(options, list =>
        {
            list.Add(new Claim("sessionid", sessionId.ToString()));
        });
    }


    /// <summary>
    /// This configures the "InterceptedScheme" to return a particular type of user, which we can also enrich with extra
    /// parameters/options for use in custom Claims
    /// </summary>
    /// <remarks>
    /// Adding a new user type:
    ///   1. Add a minimal implementation of ImpersonatedUser to be the user class (example below)
    ///   2. Add a new helper method with appropriate args typed to the new class (example above)
    /// </remarks>
    private HttpClient CreateLoggedInClient<T>(WebApplicationFactoryClientOptions options, Action<List<Claim>> configure)
        where T : ImpersonatedUser
    {
        var client = this
            .WithWebHostBuilder(builder =>
            {
                builder.ConfigureTestServices(services =>
                {
                    // configure the intercepting provider
                    services.AddTransient<IAuthenticationSchemeProvider, InterceptOidcAuthenticationSchemeProvider>();

                    // Add a "Test" scheme in to process the auth instead, using the provided user type
                    services.AddAuthentication(InterceptOidcAuthenticationSchemeProvider.InterceptedScheme)
                        .AddScheme<ImpersonatedAuthenticationSchemeOptions, T>("InterceptedScheme", options =>
                        {
                            options.OriginalScheme = SecurityConfiguration.OIDCScheme;
                            options.Configure = configure;
                        });
                });
            })
            .CreateClient(options);

        return client;
    }
}

public class GeneralUser : ImpersonatedUser
{
    public GeneralUser(IOptionsMonitor<ImpersonatedAuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
        : base(options, logger, encoder, clock)
    { }
}

With these pieces, we can now not only bypass OIDC and integration test all of our endpoints, we also have finer control over the authentication state and claims during those tests.

The full example source is here: github

A Final Note: Please stop hardcoding secrets in demo code

It's a terrible practice that borders on malpractice.

1000s of examples of AddOpenIdConnect follow this pattern, one that should not be used in any production application, ever. As an experienced .Net developer, it's difficult to find examples or guidance on not hardcoding these values. I can only imagine it is at least as difficult for someone new to .Net or early in their career and managing their 2nd or 3rd production application.

Share: