Securing an ASP.NET Core MVC application which uses a secure API

The article shows how an ASP.NET Core MVC application can implement security when using an API to retrieve data. The OpenID Connect Hybrid flow is used to secure the ASP.NET Core MVC application. The application uses tokens stored in a cookie. This cookie is not used to access the API. The API is protected using a bearer token.

To access the API, the code running on the server of the ASP.NET Core MVC application, implements the OAuth2 client credentials resource owner flow to get the access token for the API and can then return the data to the razor views.

Code: https://github.com/damienbod/AspNetCoreHybridFlowWithApi

Posts in this series:

History

2020-12-11 Updated to .NET 5
2019-10-06 Updated to .NET Core 3.0
2019-05-01 Updated packages, API calls
2019-02-24 Updated packages, API calls
2018-11-10 Updated to .NET Core 2.2
2018-05-07 Updated to .NET Core 2.1 preview 2, new Identity Views, 2FA Authenticator, IHttpClientFactory, bootstrap 4.1.0

Setup

IdentityServer4 and OpenID connect flow configuration

Two client configurations are setup in the IdentityServer4 configuration class. The OpenID Connect Hybrid Flow client is used for the ASP.NET Core MVC application. This flow, after a successful login, will return a cookie to the client part of the application which contains the tokens. The second client is used for the API. This is a service to service communication between two trusted applications. This usually happens in a protected zone. The client API uses a secret to connect to the API. The secret should be a secret and different for each deployment.

public static IEnumerable<Client> GetClients()
{
	return new List<Client>
	{
		new Client
		{
			ClientName = "hybridclient",
			ClientId = "hybridclient",
			ClientSecrets = {new Secret("hybrid_flow_secret".Sha256()) },
			AllowedGrantTypes = GrantTypes.Hybrid,
			AllowOfflineAccess = true,
			RedirectUris = { "https://localhost:44329/signin-oidc" },
			PostLogoutRedirectUris = { "https://localhost:44329/signout-callback-oidc" },
			AllowedCorsOrigins = new List<string>
			{
				"https://localhost:44329/"
			},
			AllowedScopes = new List<string>
			{
				IdentityServerConstants.StandardScopes.OpenId,
				IdentityServerConstants.StandardScopes.Profile,
				IdentityServerConstants.StandardScopes.OfflineAccess,
				"scope_used_for_hybrid_flow",
				"role"
			}
		},
		new Client
		{
			ClientId = "ProtectedApi",
			ClientName = "ProtectedApi",
			ClientSecrets = new List<Secret> { new Secret { Value = "api_in_protected_zone_secret".Sha256() } },
			AllowedGrantTypes = GrantTypes.ClientCredentials,
			AllowedScopes = new List<string> { "scope_used_for_api_in_protected_zone" }
		}
	};
}

The GetApiResources defines the scopes and the APIs for the different resources. I usually define one scope per API resource.

public static IEnumerable<ApiResource> GetApiResources()
{
	return new List<ApiResource>
	{
		new ApiResource("scope_used_for_hybrid_flow")
		{
			ApiSecrets =
			{
				new Secret("hybrid_flow_secret".Sha256())
			},
			UserClaims = { "role", "admin", "user", "some_api" }
		},
		new ApiResource("ProtectedApi")
		{
			DisplayName = "API protected",
			ApiSecrets =
			{
				new Secret("api_in_protected_zone_secret".Sha256())
			},
			Scopes =
			{
				new Scope
				{
					Name = "scope_used_for_api_in_protected_zone",
					ShowInDiscoveryDocument = false
				}
			},
			UserClaims = { "role", "admin", "user", "safe_zone_api" }
		}
	};
}

Securing the Resource API

The protected API uses the IdentityServer4.AccessTokenValidation Nuget package to validate the access token. This uses the introspection endpoint to validate the token. The scope is also validated in this example using authorization policies from ASP.NET Core.

public void ConfigureServices(IServiceCollection services)
{
	services.AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
	  .AddIdentityServerAuthentication(options =>
	  {
		  options.Authority = "https://localhost:44352";
		  options.ApiName = "ProtectedApi";
		  options.ApiSecret = "api_in_protected_zone_secret";
		  options.RequireHttpsMetadata = true;
	  });

	services.AddAuthorization(options =>
		options.AddPolicy("protectedScope", policy =>
		{
			policy.RequireClaim("scope", "scope_used_for_api_in_protected_zone");
		})
	);

	services.AddControllersWithViews(options =>
	{
	   options.Filters.Add(new MissingSecurityHeaders());
	});
}

The API is protected using the Authorize attribute and checks the defined policy. If this is ok, the data can be returned to the server part of the MVC application.

[Authorize(Policy = "protectedScope")]
[Route("api/[controller]")]
public class ValuesController : Controller
{
	[HttpGet]
	public IEnumerable<string> Get()
	{
		return new string[] { "data 1 from the second api", "data 2 from the second api" };
	}
}

Securing the ASP.NET Core MVC application

The ASP.NET Core MVC application uses OpenID Connect to validate the user and the application and saves the result in a cookie. If the identity is ok, the tokens are returned in the cookie from the server side of the application. See the OpenID Connect specification, for more information concerning the OpenID Connect Hybrid flow.

public void ConfigureServices(IServiceCollection services)
{
	services.AddAuthentication(options =>
	{
		options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
		options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
	})
	.AddCookie()
	.AddOpenIdConnect(options =>
	{
		options.SignInScheme = "Cookies";
		options.Authority = "https://localhost:44352";
		options.RequireHttpsMetadata = true;
		options.ClientId = "hybridclient";
		options.ClientSecret = "hybrid_flow_secret";
		options.ResponseType = "code id_token";
		options.Scope.Add("scope_used_for_hybrid_flow");
		options.Scope.Add("profile");
		options.SaveTokens = true;
	});

	services.AddAuthorization();

	services.AddControllersWithViews(options =>
	{
	   options.Filters.Add(new MissingSecurityHeaders());
	});
}

The Configure method adds the authentication to the MVC middleware using the UseAuthentication extension method.

public void Configure(IApplicationBuilder app)
{
	...

	app.UseStaticFiles();

	app.UseRouting();

	app.UseAuthentication();
	app.UseAuthorization();

	app.UseEndpoints(endpoints =>
	{
	   endpoints.MapControllerRoute(
	      name: "default",
	      pattern: "{controller=Home}/{action=Index}/{id?}");
	});
}

The home controller is protected using the authorize attribute, and the index method gets the data from the API using the api service.

[Authorize]
public class HomeController : Controller
{
	private readonly ApiService _apiService;

	public HomeController(ApiService apiService)
	{
		_apiService = apiService;
	}

	public async System.Threading.Tasks.Task<IActionResult> Index()
	{
		var result = await _apiService.GetApiDataAsync();

		ViewData["data"] = result.ToString();
		return View();
	}

	public IActionResult Error()
	{
		return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
	}
}

Calling the protected API from the ASP.NET Core MVC app

The API service implements the HTTP request using the TokenClient from IdentiyModel. This can be downloaded as a Nuget package. First the access token is acquired from the server, then the token is used to request the data from the API.

Use the IHttpClientFactory in the service via dependency injection. You also need to add this to the Startup services. (AddHttpClient)

private readonly IHttpClientFactory _clientFactory;

public ApiService(
IOptions<AuthConfigurations> authConfigurations, 
IHttpClientFactory clientFactory)
{
	_authConfigurations = authConfigurations;
	_clientFactory = clientFactory;
}

And the HttpClient can be used to access the protected API.

var tokenclient = _clientFactory.CreateClient();

var disco = await HttpClientDiscoveryExtensions.GetDiscoveryDocumentAsync(tokenclient, _authConfigurations.Value.StsServer);

if (disco.IsError)
{
	throw new ApplicationException($"Status code: {disco.IsError}, Error: {disco.Error}");
}

var tokenResponse = await HttpClientTokenRequestExtensions.RequestClientCredentialsTokenAsync(tokenclient, new ClientCredentialsTokenRequest
{
	Scope = "scope_used_for_api_in_protected_zone",
	ClientSecret = "api_in_protected_zone_secret",
	Address = disco.TokenEndpoint,
	ClientId = "ProtectedApi"
});

if (tokenResponse.IsError)
{
	throw new ApplicationException($"Status code: {tokenResponse.IsError}, Error: {tokenResponse.Error}");
}

var client = _clientFactory.CreateClient();

client.BaseAddress = new Uri(_authConfigurations.Value.ProtectedApiUrl);
client.SetBearerToken(tokenResponse.AccessToken);

var response = await client.GetAsync("api/values");
if (response.IsSuccessStatusCode)
{
	var responseContent = await response.Content.ReadAsStringAsync();
	var data = JArray.Parse(responseContent);

	return data;
}

throw new ApplicationException($"Status code: {response.StatusCode}, Error: {response.ReasonPhrase}");

Authentication and Authorization in the API

The ASP.NET Core MVC application calls the API using a service to service trusted association in the protected zone. Due to this, the identity which made the original request cannot be validated using the access token on the API. If authorization is required for the original identity, this should be sent in the URL of the API HTTP request, which can then be validated as required using an authorization filter. Maybe it is enough to validate that the service token is authenticated, and authorized. Care should be taken when sending user data, GDPR requirements, or user information which the IT admins should not have access to.

Should I use the same token as the access token returned to the MVC client?

This depends 🙂 If the API is a public API, then this is fine, if you have no problem re-using the same token for different applications. If the API is in the protected zone, for example behind a WAF, then a separate token would be better. Only tokens issued for the trusted app can be used to access the protected API. This can be validated by using separate scopes, secrets, etc. The tokens issued for the MVC app and the user, will not work, these were issued for a single purpose only, and not multiple applications. The token used for the protected API never leaves the trusted zone.

Links

https://docs.microsoft.com/en-gb/aspnet/core/mvc/overview

https://docs.microsoft.com/en-gb/aspnet/core/security/anti-request-forgery

https://docs.microsoft.com/en-gb/aspnet/core/security/

OpenID

Click to access Best_Practices_WAF_v105.en.pdf

https://tools.ietf.org/html/rfc7662

http://docs.identityserver.io/en/release/quickstarts/5_hybrid_and_api_access.html

https://github.com/aspnet/Security

Identity Server: From Implicit to Hybrid Flow

http://openid.net/specs/openid-connect-core-1_0.html#HybridFlowAuth

5 comments

  1. […] Securing an ASP.NET Core MVC application which uses a secure API (Damien Bowden) […]

  2. […] Securing an ASP.NET Core MVC application which uses a secure API – Damien Bowden […]

  3. […] Securing an ASP.NET Core MVC application which uses a secure API (Damien Bowden) […]

  4. What would need to be configured in the case of having an MVC client, an API gateway, and 4 APIs (with 1 API aggregating the 3 APIs with custom logic)?

    I’m not sure if this is the config would work in this situation:

    – Clients
    – MVC client
    id: mvc_client
    grant: hybrid
    scopes: aggregator_api api1 api2 api3

    – Aggregator API
    id: aggregator_api
    grant: client_credentials
    scopes: api1 api2 api3

    -API Resources

    – Aggregator API
    id: aggregator_api
    scopes: api1 api2 api3
    -API 1
    id: api1
    scopes: api1
    -API 2
    id: api2
    scopes: api2
    -API 3
    id: api3
    scopes: api3

    What I want to happen is for an MVC client to be able to hit the Aggregator API. The Aggregator API will issue requests to api1, api2, and api3.

  5. Hi,

    As per my requirement I want to provide client details like clientid, secret etc values through an input form by testers. I want to pass on these input values to AddOpenIDConnect method in startup class. How can I achieve that?

    please reply ASAP

    Thanks

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.