Search code examples
c#asp.net-coreodatamulti-tenant

GetRouteData always null using AspNetCore.OData 7.2.1


I am trying to secure an OData api using .net Core 2.2 and AspNetCore.OData 7.2.1, with a basic authentication handler. I need to handler multi tenant urls, and retrieve from the uri the token that will then be used in the authorization handler to determine if a user is authorized.

To do so I use the IHttpContextAccessor but this works only with standard api, and not with OData.

OData does not like EndpointRouting and I had to disable it as shown below, but in this case then how can I access the RouteData to take the tenant token?

Is there an alternative approach? Below the code you can use to try this out.

Startup.cs

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

public IConfiguration Configuration { get; }

// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
    services.AddHttpContextAccessor();
    services.AddAuthentication("BasicAuthentication")
        .AddScheme<AuthenticationSchemeOptions, BasicAuthenticationHandler>("BasicAuthentication", null);

    services.AddMvc(options => options.EnableEndpointRouting = false)
        .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
    services.AddOData();
}

// 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();
    }

    // Needed to be able to get RouteData from HttpContext through the IHttpContextAccessor
    app.UseEndpointRouting();
    // Needed to secure the application using the standard Authorize attribute
    app.UseAuthentication();

    // OData entity model builder
    var builder = new ODataConventionModelBuilder(app.ApplicationServices);
    builder.EntitySet<Value>(nameof(Value) + "s");

    app.UseMvc();
    app.UseOData("odata", "{tenant}/odata", builder.GetEdmModel());

// Alternative configuration which is affected by the same problem
//
//  app.UseMvc(routeBuilder =>
//  {
//      // Map OData routing adding token for the tenant based url
//      routeBuilder.MapODataServiceRoute("odata", "{tenant}/odata", builder.GetEdmModel());
//  
//      // Needed to allow the injection of OData classes
//      routeBuilder.EnableDependencyInjection();
//  });
}

BasicAuthenticationHandler.cs

public class BasicAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public BasicAuthenticationHandler(
        IOptionsMonitor<AuthenticationSchemeOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder,
        ISystemClock clock,
        IHttpContextAccessor httpContextAccessor)
        : base(options, logger, encoder, clock)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    public string GetTenant()
    {
        var httpContext = _httpContextAccessor?.HttpContext;
        var routeData = httpContext?.GetRouteData(); // THIS RESULTS ALWAYS IN NULL ROUTE DATA!
        return routeData?.Values["tenant"]?.ToString();
    }

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!Request.Headers.ContainsKey("Authorization"))
            return AuthenticateResult.Fail("Missing Authorization Header");

        try {
            var authHeader = AuthenticationHeaderValue.Parse(Request.Headers["Authorization"]);

            var credentialBytes = Convert.FromBase64String(authHeader.Parameter);
            var credentials = Encoding.UTF8.GetString(credentialBytes).Split(':');
            var username = credentials[0];
            var password = credentials[1];

            var tenant = GetTenant();

            if (string.IsNullOrEmpty(tenant))
            {
                return AuthenticateResult.Fail("Unknown tenant");
            }

            if(string.IsNullOrEmpty(username) || username != password)
                return AuthenticateResult.Fail("Wrong username or password");
    }
        catch (Exception e)
        {
            return AuthenticateResult.Fail("Unable to authenticate");
        }

        var claims = new[] {
            new Claim("Tenant", "tenant id")
        };

        var identity = new ClaimsIdentity(claims, Scheme.Name);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, Scheme.Name);

        return AuthenticateResult.Success(ticket);
    }

    protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
    {
        Response.Headers["WWW-Authenticate"] = "Basic realm=\"Oh my OData\", charset=\"UTF-8\"";
        await base.HandleChallengeAsync(properties);
    }
}

Value.cs

public class Value
{
    public int Id { get; set; }
    public string Name { get; set; }
}

ValuesController.cs

[Authorize]
public class ValuesController : ODataController
{
    private List<Value> _values;

    public ValuesController()
    {
        _values = new List<Value>
        {
            new Value {Id = 1, Name = "A1"},
            new Value {Id = 2, Name = "A2"},
            new Value {Id = 3, Name = "A3"},
            new Value {Id = 4, Name = "A4"},
            new Value {Id = 5, Name = "A5"},
            new Value {Id = 6, Name = "A6"},
            new Value {Id = 7, Name = "A7"},
            new Value {Id = 11, Name = "B1"},
            new Value {Id = 12, Name = "B2"},
            new Value {Id = 13, Name = "B3"},
            new Value {Id = 14, Name = "B4"},
            new Value {Id = 15, Name = "B5"},
            new Value {Id = 16, Name = "B6"},
            new Value {Id = 17, Name = "B7"}
        };
    }

    // GET {tenant}/odata/values
    [EnableQuery]
    public IQueryable<Value> Get()
    {
        return _values.AsQueryable();
    }

    // GET {tenant}/odata/values/5
    [EnableQuery]
    public ActionResult<Value> Get([FromODataUri] int key)
    {
        if(_values.Any(v => v.Id == key))
            return _values.Single(v => v.Id == key);

        return NotFound();
    }
}

EDIT: Added sample code in working application to reproduce the issue and test solutions: https://github.com/norcino/so-58016881-OData-GetRoute


Solution

  • OData does not like EndpointRouting and I had to disable it as shown below, but in this case then how can I access the RouteData to take the tenant token?

    As you know, OData doesn't work fine with ASP.NET Core 2.2 EndPoint Routing. For more details at present, see https://github.com/OData/WebApi/issues/1707

    var routeData = httpContext?.GetRouteData(); // THIS RESULTS ALWAYS IN NULL ROUTE DATA!

    The reason why you always get a null route data is that the Authentication middleware runs before the Router middleware takes effect. In other words, you won't get route data before the Router middleware is invoked.

    To walk around it, just create a router and make it runs before the Authentication middleware.

    How to fix

    1. Make sure you've disabled the EnableEndpointRouting:

      services.AddMvc(
          options => options.EnableEndpointRouting = false
      )
      .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);   
      
    2. Remove the line of app.UseEndpointRouting():

      // OData doesn't work fine with ASP.NET Core 2.2 EndPoint Routing, See https://github.com/OData/WebApi/issues/1707
      // app.UseEndpointRouting();  
      
    3. Set up a Router before Authentication so that you can get the Route Data within AuthenticationHandler later:

      // configure Routes for OData
      app.UseRouter(routeBuilder =>{
          var templatePrefix="{tenant}/odata";
          var template = templatePrefix + "/{*any}";
          routeBuilder.MapMiddlewareRoute(template, appBuilder =>{
              var builder = new ODataConventionModelBuilder(app.ApplicationServices);
              builder.EntitySet<Value>(nameof(Value) + "s");
              appBuilder.UseAuthentication();
              appBuilder.UseMvc();
              appBuilder.UseOData("odata", templatePrefix, builder.GetEdmModel());
          });
      });
      
      // ... add more middlewares if you want other MVC routes
      app.UseAuthentication();
      app.UseMvc(rb => {
          rb.MapRoute("default","{controller=Home}/{action=Index}/{id?}");
      });
      

    Demo

    1. Send a request to the Values API

      GET https://localhost:5001/msft/odata/values
      Authorization: Basic dGVzdDp0ZXN0
      
    2. And then we'll get the route data as below:

      enter image description here