Post-Logout Redirect with ASP.NET Core and ADFS 2016

Redirect after logout

When a user logs out from your app you have the option to log them out of the provider as well by redirecting the browser to the logout endpoint. By default this means that the user will end up sat on your providers “You have signed out” page – not brilliant.

You can, however, tell your provider to redirect back to your app once they’re done with logout by specifying a post_logout_redirect_uri.

For ASP.NET Core Identity you can specify this redirection as a parameter on the SignOutResult.

[Route("auth")]
public class AuthController
{
  [HttpPost("logout")]
  public IActionResult Logout() => new SignOutResult(
    OpenIdConnectDefaults.AuthenticationScheme,
    new AuthenticationProperties { RedirectUri = Url.Action(nameof(LogoutSuccess))});

  [HttpGet("logoutSuccess")]
  public IActionResult LogoutSuccess() => View();
}

Useless ADFS error messages

For ADFS 2016 you need to do a little bit more than just set the redirect URL. On first inspection you can see that the above will set the parameter in the ADFS URL but ADFS will silently ignore it and your user will sit forever on the ADFS sign-out page.

Digging into the event logs you will find the following error message:

The specified redirect URL did not match any of the OAuth client’s redirect URIs. The logout was successful but the client will not be redirected

If you’re unlucky, this wonderfully-misleading error message can take you down a rabbit hole of further configuration. It’s a shame, then, that no-one thought to expose something a little more accurate:

That redirect looks good but you need to specify id_token_hint or we’ll ignore you

Thanks ADFS!

Sending ID Token Hint

To be fair to ADFS, sending an id_token_hint is recommended by the spec. This parameter needs to be set to the id_token that was sent to your app when the user first logged in; provide this value and ADFS will happily redirect back to your app.

The only problem here is that you probably don’t still have that id_token. ASP.NET Core Identity uses the identity information to create an auth cookie and then (by default) discards it.

Happily, the OpenIdConnectOptions exposes a SaveTokens property to persist the received token to the auth cookie. Even better: the OIDC logout mechanism will automatically pick this up once enabled so you should be good to go as soon as you set the flag:

public class Startup {
  public void ConfigureServices(IServiceCollection services)
  {
    services.AddAuthentication()
      .AddOpenIdConnect(options => {
        options.SaveTokens = true;
        //...set other options
      });

    //...other service config
  }
}

This does have one important downside though: you’re now storing much more information in your auth cookie and that adds extra data to every client request, maybe even doubling the cookie size.

Whether or not this is a problem for your app is another decision – at least your logout redirect works now!

Advertisements

Generic behaviour in ASP.NET Core with Action Filters

Everyone hates copy/pasting code, and Action Filters in ASP.NET Core MVC offer a great way to avoid filling your controllers with boilerplate.

Filters offer you entry points into the execution pipeline for an action where you can examine the incoming parameters or generated results and modify them to suit your needs.

Here are a couple of examples of how this can help.

Treat a null result as a 404 Not Found

By default an ASP.NET Core controller will return a 204 No Content response if you return null from an action:

[Route("api/example")]
public class ExampleApiController : Controller
{
  [HttpGet("")]
  public string GetExample()
  {
    return null;
  }
}

In some cases, however, you might not want to treat null as No Content. If your API is looking up a resource by ID, for example, then a 404 Not Found response would be more useful:

[HttpGet("{id}")]
public MyDtoObject GetById(int id)
{
  if (!_store.ContainsId(id))
    return null; //should return 404

  //...
}

We can use an action filter to automatically replace the null result with a NotFoundResult:

//filter
public class NullAsNotFoundAttribute : ActionFilterAttribute
{
  public override void OnActionExecuted(ActionExecutedContext context)
  {
    var objectResult = context.Result as ObjectResult;

    if (objectResult?.Value == null)
      context.Result = new NotFoundResult();
  }
}

//controller
[HttpGet("{id}")]
[NullAsNotFound]
public MyDtoObject GetById(int id)
{
  //...
}

Here we override OnActionExecuted to invoke our code after the action method has generated a result but before that result is processed.

If the generated result is an ObjectResult with a null value then we replace it with an empty NotFoundResult and our controller will now return a 404 response.

Treat invalid models as a 400 Bad Request

It is very common to see the following pattern in MVC controllers:

[HttpPost("")]
public IActionResult Create(MyModel model)
{
  if (!ModelState.IsValid)
    return new BadRequestObjectResult(ModelState); //or a View, or other validation behaviour

  //...eventually return created model
  return Ok(model);
}

This has 2 downsides:
* Boilerplate code in every action that needs to validate
* Return type must be IActionResult to accomodate 2 result types

The second point is fairly minor but worth noting. By exposing IActionResult instead of the concrete type we lose metadata about the action.
That metadata is useful for things like generating swagger docs, and losing it can mean you need to decorate the method with response types (though this is improved in ASP.NET Core 2.1 with IActionResult).

In any case, we can make this behaviour generic by moving the validation check into another action filter:

public class InvalidModelAsBadRequestAttribute : ActionFilterAttribute
{
  public override void OnActionExecuting(ActionExecutingContext context)
  {
    if (!context.ModelState.IsValid)
      context.Result = new BadRequestObjectResult(context.ModelState);
  }
}

This time we are overriding OnActionExecuting instead of OnActionExecuted so our code gets run before the controller action. We can tell if the model is invalid before hitting our controller so we can skip the action entirely if we know it should be replaced with a 400.

Other Possibilities

Wherever you find yourself writing duplicate code in many actions it is worth considering whether it can be pulled out into a filter (or middleware) to keep your controllers clean and focussed on their intent.