In the second part of this series, we were talking about securing Web API. But, we used the ResourceOwnerPassword and the ClientCredentials flows and Postman as a client. But now, we have our MVC client application, secured with the Hybrid Flow, which requires access to the Web API. So, securing Web API with the Hybrid flow requires additional configuration and this is going to be our goal for this article. Additionally, we are going to learn how we can use policies instead of roles to secure endpoints in our application.

To download the source code for the client application, you can visit the Securing Web API repository.

We highly recommend visiting the IdentityServer4 series page to learn about all the articles in this series because this article is strongly related to the previous ones.

So, let’s get down to business.

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

Securing Web API – Adding Additional Configuration

As we said, we have already added most of the required configuration for the IdentityServer and our Web API.

We have set up ApiResource and ApiScopes in the InMemoryConfig class:

public static IEnumerable<ApiScope> GetApiScopes() =>
   new List<ApiScope> { new ApiScope("companyApi", "CompanyEmployee API") };

public static IEnumerable<ApiResource> GetApiResources() =>
   new List<ApiResource>
   {
       new ApiResource("companyApi", "CompanyEmployee API")
       {
           Scopes = { "companyApi" }
       }
   };

Configuration for the Web API client:

new Client
{
    ClientId = "company-employee",
    ClientSecrets = new [] { new Secret("codemazesecret".Sha512()) },
    AllowedGrantTypes = GrantTypes.ResourceOwnerPasswordAndClientCredentials,
    AllowedScopes = { IdentityServerConstants.StandardScopes.OpenId, "companyApi" }
},

Lastly, we can find the configuration in the Web API project to communicate with the IdentityServer project:

services.AddAuthentication("Bearer")
    .AddJwtBearer("Bearer", opt =>
    {
        opt.RequireHttpsMetadata = false;
        opt.Authority = "https://localhost:5005";
        opt.Audience = "companyApi";
    });

This enables back-channel communication between the Web API project and our IdentityServer, where API asks for a public key from IdentityServer to validate the access_token.

But now we have our client application that communicates with the Web API project and we need to add additional steps to complete the Hybrid Flow cycle and protect our API.

So, the first thing we have to do is to add a scope to the MvcClient in the IdentityServer configuration:

new Client
{
    ClientName = "MVC Client",
    ClientId = "mvc-client",
    AllowedGrantTypes = GrantTypes.Hybrid,
    RedirectUris = new List<string>{ "https://localhost:5010/signin-oidc" },
    AllowedScopes =
    { 
        IdentityServerConstants.StandardScopes.OpenId, 
        IdentityServerConstants.StandardScopes.Profile, 
        IdentityServerConstants.StandardScopes.Address,
        "roles",
        "companyApi"
    },
    ClientSecrets = { new Secret("MVCSecret".Sha512()) },
    PostLogoutRedirectUris = new List<string> { "https://localhost:5010/signout-callback-oidc" }
    RequireConsent = true
}

The companyApi value comes from the ApiResource configuration and we have to add it to the allowed scopes of our MVC client application.

Next, let’s modify the client’s OIDC configuration by adding a scope:

.AddOpenIdConnect("oidc", opt =>
{
    //previous code
    opt.ClaimActions.MapUniqueJsonKey("roles", "role");

    opt.TokenValidationParameters = new TokenValidationParameters
    {
        RoleClaimType = "roles"
    };

    opt.Scope.Add("companyApi");
});

Finally, we have to uncomment the [Authorize] attribute in the Web API’s Companies controller:

[Authorize]
[HttpGet]
public IActionResult GetCompanies()

Now, we can start the IdentityServer, Web API, and client application. As soon as we log in, we are going to see a different consent screen:

CompanyEmployee API access - Securing Web API

But, once we click the Allow button, we are going to get an error page:

Unauthorized client access to API - Securing Web API

Even though we have all these configurations in place, we are still missing something.

Providing Access Token for Each Call to the API

Before we dive into the code, let’s explain the reason our client is still unauthorized. Well, as we learned in the third part of this series, the Hybrid flow has several steps. Our client sends a request for the code and id_token to the /authorization endpoint. IdentityServer returns them to the client. Then the client validates the id_token and if it’s valid it sends another request with the code to the /token endpoint. IdentityServer issues the access_token and id_token.

So, the client has the access_token, but it must provide that token as a Bearer token in the Authorization header (as we did in the second part of this series with the Postman). But right now, our client application is not using that access token at all. That’s why we get an Unauthorized response.

To provide access token as a Bearer token, we have to modify the GetClient method in the CompanyHttpClient class in the client application:

public async Task<HttpClient> GetClient()
{
    var accessToken = await _httpContextAccessor
        .HttpContext
        .GetTokenAsync(OpenIdConnectParameterNames.AccessToken);

    if (!string.IsNullOrWhiteSpace(accessToken))
    {
        _httpClient.SetBearerToken(accessToken);
    }

    _httpClient.BaseAddress = new Uri("https://localhost:5001/");
    _httpClient.DefaultRequestHeaders.Accept.Clear();
    _httpClient.DefaultRequestHeaders.Accept.Add(
       new MediaTypeWithQualityHeaderValue("application/json"));

    return _httpClient;
}

So, we extract the access token from the HttpContext by using the _httpContextAccessor object and the GetTokenAsync method. If we find the token, we store it as a Bearer token with the SetBearerToken method. For all of these to work, we have to add additional using directives:

using IdentityModel.Client;
using Microsoft.AspNetCore.Authentication;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;

Now, we can start our client app and login again. This time, we can see the data from the API.

Redirecting the Client to the Unauthorized Page

It is not a good user experience to show the generic error page when an unauthorized user tries to access a protected page. Moreover, we have the AccessDenied page created. So, all we have to do is redirect an unauthorized user to that page.

To do that, let’s modify the Index action in the Home controller:

public async Task<IActionResult> Index()
{
    var httpClient = await _companyHttpClient.GetClient();

    var response = await httpClient.GetAsync("api/companies").ConfigureAwait(false);

    if (response.IsSuccessStatusCode)
    {
        var companiesString = await response.Content.ReadAsStringAsync().ConfigureAwait(false);

        var companyViewModel = JsonConvert.DeserializeObject<List<CompanyViewModel>>(companiesString).ToList();

        return View(companyViewModel);
    }
    else if(response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden)
    {
        return RedirectToAction("AccessDenied", "Account");
    }

    throw new Exception($"Problem with fetching data from the API: {response.ReasonPhrase}");
}

So, we check the StatusCode property from our response. If it is unauthorized or forbidden, we redirect a user to the AccessDenied page.

Now, let’s try this. But, on the consent page, we are not going to allow the API access:

Declined API access - Securing Web API

After we click the Allow button, we are going to see the AccessDenied page for sure.

Securing Application with Policies

In the previous article, we explained how to use Role Base Authorization (RBA) to protect our application. But, there is another approach – Attribute-based Access Control (ABAC), which is more suited for the authorization with complex rules. When we talk about the complex rules, we mean something like the user has to be authenticated and it must have a certain title and from a certain country, for example. So, as you can see, there are several rules for authorization to be completed, and the best way to configure that is by using the ABAC approach. This approach is also known as Policy-based Access Control because it uses policies to grant access for users to protected resources.

So, let’s learn how to use Policies in our client application.

We have to modify the configuration class on the IDP level first. So, let’s add new identity resources:

public static IEnumerable<IdentityResource> GetIdentityResources() =>
    new List<IdentityResource>
    {
        new IdentityResources.OpenId(),
        new IdentityResources.Profile(),
        new IdentityResources.Address(),
        new IdentityResource("roles", "User role(s)", new List<string> { "role" }),
        new IdentityResource("position", "Your position", new List<string> { "position" }),
        new IdentityResource("country", "Your country", new List<string> { "country" })
    };

We want our client to request these scopes, so let’s modify allowed scopes for the MVC client:

AllowedScopes =
{ 
    IdentityServerConstants.StandardScopes.OpenId, 
    IdentityServerConstants.StandardScopes.Profile, 
    IdentityServerConstants.StandardScopes.Address,
    "roles",
    "companyApi",
    "position",
    "country"
}

Finally, let’s add these claims to our users:

public static List<TestUser> GetUsers() =>
    new List<TestUser>
    {
        new TestUser
        {
            SubjectId = "a9ea0f25-b964-409f-bcce-c923266249b4",
            Username = "Mick",
            Password = "MickPassword",
            Claims = new List<Claim>
            {
                new Claim("given_name", "Mick"),
                new Claim("family_name", "Mining"),
                new Claim("address", "Sunny Street 4"),
                new Claim("role", "Admin"),
                new Claim("position", "Administrator"),
                new Claim("country", "USA")
            }
        },
        new TestUser
        {
            SubjectId = "c95ddb8c-79ec-488a-a485-fe57a1462340",
            Username = "Jane",
            Password = "JanePassword",
            Claims = new List<Claim>
            {
                new Claim("given_name", "Jane"),
                new Claim("family_name", "Downing"),
                new Claim("address", "Long Avenue 289"),
                new Claim("role", "Visitor"),
                new Claim("position", "Viewer"),
                new Claim("country", "USA")
            }
        }
    };

Excellent. That’s it regarding the IDP level modifications.

Now, on the client level, we have to modify the OIDC configuration with familiar actions:

.AddOpenIdConnect("oidc", opt =>
{
    //previous code

    opt.Scope.Add("companyApi");

    opt.Scope.Add("position");
    opt.Scope.Add("country");
    opt.ClaimActions.MapUniqueJsonKey("position", "position");
    opt.ClaimActions.MapUniqueJsonKey("country", "country");
});

And, we have to create our policies right above the AddControllersWithViews method:

services.AddAuthorization(authOpt =>
{
    authOpt.AddPolicy("CanCreateAndModifyData", policyBuilder =>
    {
        policyBuilder.RequireAuthenticatedUser();
        policyBuilder.RequireClaim("position", "Administrator");
        policyBuilder.RequireClaim("country", "USA");
    });
});

services.AddControllersWithViews();

We use the AddAuthorization method to add Authorization in the service collection. By using the AddPolicy method, we provide the policy name and all the policies required for the authorization process to complete. As you can see, we require a user to be an authenticated administrator from the USA.

So, once we log in, we are going to see additional scopes in our consent screen:

Additional Scopes Country and Position

And, if we navigate to the Privacy page, we are going to see additional claims for sure.

But, we are not using our created policy for authorization. At least not yet. So, let’s change that.

Using Policies

We are going to modify the Index view first:

@if ((await AuthorizationService.AuthorizeAsync(User, "CanCreateAndModifyData")).Succeeded)
{
    <p>
        <a asp-action="Create">Create New</a>
    </p>
}

//additional code

@if ((await AuthorizationService.AuthorizeAsync(User, "CanCreateAndModifyData")).Succeeded)
{
    <td>
        @Html.ActionLink("Edit", "Edit", new { /* id=item.PrimaryKey */ }) |
        @Html.ActionLink("Details", "Details", new { /* id=item.PrimaryKey */ }) |
        @Html.ActionLink("Delete", "Delete", new { /* id=item.PrimaryKey */ })
    </td>
}

And finally, let’s modify the [Authorize] attribute for the Privacy action:

[Authorize(Policy = "CanCreateAndModifyData")]
public async Task<IActionResult> Privacy()

And that’s it. We can test this with both Mick and Jane. For sure with Jane’s account, we won’t be able to see Create, Update, Delete, and Details link and we are going to be redirected to the AccessDenied page if we try to navigate to the Privacy page. You can try it out.

Conclusion

Excellent.

In this article, we have learned how to implement the Hybrid Flow to protect our Web API. Additionally, we’ve learned how to place an access_token to each call from our client to the API and how to use Policies to protect our application.

In the next article, we are going to show you how to transfer all the in-memory configuration to the database.

Until then,

Best regards.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!