Quantcast
Channel: MVC – Software Engineering
Viewing all 96 articles
Browse latest View live

Securing an ASP.NET Core Razor Page App using OpenID Connect Code flow with PKCE

$
0
0

This article shows how to secure an ASP.NET Core Razor Page application using the Open ID Connect code flow with PKCE (Proof Key for Code Exchange). The secure token server is implemented using IdentityServer4 but any STS could be used which supports PKCE.

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

An ASP.NET Core 3.0 Razor Page application without identity was created using the Visual Studio templates. The Microsoft.AspNetCore.Authentication.OpenIdConnect Nuget package was then added to the project.

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>netcoreapp3.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="3.0.0" />
  </ItemGroup>

</Project>

In the startup class, the ConfigureServices method is used to add the authentication and the authorization. Cookies is used to persist the session, if authorized, and OpenID Connect is used to signin, signout. If a new session is started, the application redirects to IdentityServer4 and secures both the identity and the application using the OpenID Connect code flow with PKCE (Proof key for code exchange). Both the PKCE and the secret are required.

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 = "codeflowpkceclient";
	   options.ClientSecret = "codeflow_pkce_client_secret";
	   options.ResponseType = "code";
	   options.UsePkce = true;
	   options.Scope.Add("profile");
	   options.Scope.Add("offline_access");
	   options.SaveTokens = true;
   });

   services.AddAuthorization();
   services.AddRazorPages();
}

The Configure method adds the middleware so that the authorization is used. Both the UseAuthentication() and UseAuthorization() methods are required, and must be added after the AddRouting() method.

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
	// code not needed for example
	app.UseStaticFiles();

	app.UseRouting();

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

	app.UseEndpoints(endpoints =>
	{
		endpoints.MapRazorPages();
	});
}

IdentityServer4 is configured to accept the client configuration from above. Both the PKCE and the secret are required. The configuration must match the client configuration exactly. In a production application, the secrets must be removed from the code and read from a safe configuration like for example Azure key vault. The URLs would also be read from app.settings or something like this.

new Client
{
	ClientName = "codeflowpkceclient",
	ClientId = "codeflowpkceclient",
	ClientSecrets = {new Secret("codeflow_pkce_client_secret".Sha256()) },
	AllowedGrantTypes = GrantTypes.Code,
	RequirePkce = true,
	RequireClientSecret = true,
	AllowOfflineAccess = true,
	AlwaysSendClientClaims = true,
	UpdateAccessTokenClaimsOnRefresh = true,
	AlwaysIncludeUserClaimsInIdToken = true,
	RedirectUris = {
		"https://localhost:44330/signin-oidc",
	},
	PostLogoutRedirectUris = {
		"https://localhost:44330/signout-callback-oidc",
	},
	AllowedScopes = new List<string>
	{
		IdentityServerConstants.StandardScopes.OpenId,
		IdentityServerConstants.StandardScopes.Profile,
		IdentityServerConstants.StandardScopes.OfflineAccess
	}
}

The Authorize attribute needs to be added to all pages which are to be secured. You could also require that the whole application is to be secure and opt out for the non-secure pages. If the page is called in a browser, the application will automatically redirect the user, application to authenticate.

[Authorize]
public class IndexModel : PageModel
{
	private readonly ILogger<IndexModel> _logger;

	public IndexModel(ILogger<IndexModel> logger)
	{
		_logger = logger;
	}

	public void OnGet()
	{

	}
}

The application also needs a signout. This is implemented using two new pages, a logout page, and a SignedOut page. If the user clicks the logout link, the application removes the session and redirects to a public page of the application.

[Authorize]
public class LogoutModel : PageModel
{
	public async Task<IActionResult> OnGetAsync()
	{
		await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);

		return Redirect("/SignedOut");
	}
}

Now the Razor page application, identity can signin, signout using OpenID Connect Code Flow with PKCE and also uses a secret to authorize the client.

Links:

https://openid.net/specs/openid-connect-core-1_0.html

https://docs.microsoft.com/en-us/aspnet/core/security/?view=aspnetcore-3.0

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

https://docs.microsoft.com/en-us/aspnet/core/razor-pages


Securing a Web API using multiple token servers

$
0
0

This article shows how a single secure Web API could be used together with multiple secure token servers. The API uses JWT Bearer token authentication, but because the access token come from different token servers, the tokens validation need to be changed.

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

Using multiple Authorities with shared certitficate

The first way this can be supported, is that the Authority option is removed from the AddJwtBearer code configuration. When this is removed, the JWT Bearer has no way of validating the Issuer signing. The same certificate which signed the access token, needs to be used in the API as well as the token server. This is easy if you have control of all the applications and can read the certificate from a shared resource like Azure Key Vault. Multiple Issuers can then be supported by configuring the TokenValidationParameters where the signing key is also coded.

public void ConfigureServices(IServiceCollection services)
{
	var x509Certificate2 = GetCertificate(_environment);

	services.AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
	   .AddJwtBearer(options =>
		{
			options.Audience = "ProtectedApiResource";
			options.TokenValidationParameters = new TokenValidationParameters
			{
				ValidateIssuer = true,
				ValidIssuers = new List<string> { "https://localhost:44318", "https://localhost:44367" },
				ValidateIssuerSigningKey = true,
				IssuerSigningKey = new X509SecurityKey(x509Certificate2),
				IssuerSigningKeyResolver =
				(string token, SecurityToken securityToken, string kid, TokenValidationParameters validationParameters)
				  => new List<X509SecurityKey> { new X509SecurityKey(x509Certificate2) }
			};
		});

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

	services.AddControllers();
}

Using multiple Schemes

A different solution would be to use multiple Authentication schemes, a different one for each token service. The Schemes are then added to the default authorization policy. The example shown underneath requires a scope claim scope_used_for_api_in_protected_zone.

public void ConfigureServices(IServiceCollection services)
{
	var x509Certificate2 = GetCertificate(_environment);

	services.AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
		.AddJwtBearer("JWT", options =>
		{
			options.Audience = "ProtectedApiResourceOne";
			options.Authority = "https://localhost:44318";
		})
		.AddJwtBearer("Custom", options =>
		{
			options.Audience = "ProtectedApiResourceTwo";
			options.Authority = "https://localhost:44367";
		});

	services.AddAuthorization(options =>
	{
		options.DefaultPolicy = new AuthorizationPolicyBuilder()
			.RequireAuthenticatedUser()
			.AddAuthenticationSchemes("JWT", "Custom")
			.Build();

		options.AddPolicy("protectedScope", policy =>
		{
			// scope is required in token from both servers
			policy.RequireClaim("scope", "scope_used_for_api_in_protected_zone");
		});
	  });

	services.AddControllers();
}

Deploy two seperate Web APIs

My favourite solution would be to deploy 2 separate APIs each using a different token service. This would require two separate deployments.

All solutions have advantages, and disadvantages.

Links:

https://docs.microsoft.com/en-us/aspnet/core/security/authorization/limitingidentitybyscheme

https://docs.microsoft.com/en-us/dotnet/framework/security/json-web-token-handler

User claims in ASP.NET Core using OpenID Connect Authentication

$
0
0

This article shows two possible ways of getting user claims in an ASP.NET Core application which uses an IdentityServer4 service. Both ways have advantages and require setting different code configurations in both applications.

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

To use OpenID Connect in an ASP.NET Core application, the Microsoft.AspNetCore.Authentication.OpenIdConnect package can be used. This needs to be added as a reference in the project.

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>netcoreapp3.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="3.0.0" />
  </ItemGroup>

</Project>

Option 1: Returning the claims in the id_token

The profile claims can be returned in the id_token which is returned after a successful authentication. The ASP.NET Core client application just needs to request the profile scope.

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 = "codeflowpkceclient";
	   options.ClientSecret = "codeflow_pkce_client_secret";
	   options.ResponseType = "code";
	   options.UsePkce = true;
	   options.Scope.Add("profile");
	   options.Scope.Add("offline_access");
	   options.SaveTokens = true;
   });

   services.AddAuthorization();
   services.AddRazorPages();
}

In IdentityServer4, the corresponding client configuration uses the AlwaysIncludeUserClaimsInIdToken property to include the user profile claims in the id_token. By implementing the IProfileService, any claims can be added.

With this, all claims will be returned in the id_token and can then be used in the client application. This increases the size of the token, which might be important if you add to many claims. All values will be included and available in the User.Identity context in the client application.

new Client
{
	ClientName = "codeflowpkceclient",
	ClientId = "codeflowpkceclient",
	ClientSecrets = {new Secret("codeflow_pkce_client_secret".Sha256()) },
	AllowedGrantTypes = GrantTypes.Code,
	RequirePkce = true,
	RequireClientSecret = true,
	AllowOfflineAccess = true,
	AlwaysSendClientClaims = true,
	UpdateAccessTokenClaimsOnRefresh = true,
	AlwaysIncludeUserClaimsInIdToken = true,
	RedirectUris = {
		"https://localhost:44330/signin-oidc",
		$"{codeFlowClientUrl}/signin-oidc"
	},
	PostLogoutRedirectUris = {
		"https://localhost:44330/signout-callback-oidc",
		$"{codeFlowClientUrl}/signout-callback-oidc"
	},
	AllowedScopes = new List<string>
	{
		IdentityServerConstants.StandardScopes.OpenId,
		IdentityServerConstants.StandardScopes.Profile,
		IdentityServerConstants.StandardScopes.OfflineAccess,
		"role"
	}
}

Option 2: Returning the claims using the UserInfo API

A second way to get the user claims is to use the OpenID Connect User Info API. The ASP.NET Core client application uses the GetClaimsFromUserInfoEndpoint property to configure this. One important difference to option 1, is that you MUST specify the claims you require using the MapUniqueJsonKey method, otherwise only the name, given_name and email standard claims will be available in the client application. The claims included in the id_token are mapped per default. This is the major difference to the first option. You must explicit define some of the standard claims you require.

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 = "codeflowpkceclient";
	   options.ClientSecret = "codeflow_pkce_client_secret";
	   options.ResponseType = "code";
	   options.UsePkce = true;
	   options.Scope.Add("profile");
	   options.Scope.Add("offline_access");
	   options.SaveTokens = true;
	   options.GetClaimsFromUserInfoEndpoint = true;
	   options.ClaimActions.MapUniqueJsonKey("preferred_username", "preferred_username");
	   options.ClaimActions.MapUniqueJsonKey("gender", "gender");
   });

   services.AddAuthorization();
   services.AddRazorPages();
}

The IdentityServer4 can be configured without the AlwaysIncludeUserClaimsInIdToken set.

new Client
{
	ClientName = "codeflowpkceclient",
	ClientId = "codeflowpkceclient",
	ClientSecrets = {new Secret("codeflow_pkce_client_secret".Sha256()) },
	AllowedGrantTypes = GrantTypes.Code,
	RequirePkce = true,
	RequireClientSecret = true,
	AllowOfflineAccess = true,
	AlwaysSendClientClaims = true,
	UpdateAccessTokenClaimsOnRefresh = true,
	//AlwaysIncludeUserClaimsInIdToken = true,
	RedirectUris = {
		"https://localhost:44330/signin-oidc",
		$"{codeFlowClientUrl}/signin-oidc"
	},
	PostLogoutRedirectUris = {
		"https://localhost:44330/signout-callback-oidc",
		$"{codeFlowClientUrl}/signout-callback-oidc"
	},
	AllowedScopes = new List<string>
	{
		IdentityServerConstants.StandardScopes.OpenId,
		IdentityServerConstants.StandardScopes.Profile,
		IdentityServerConstants.StandardScopes.OfflineAccess,
		"role"
	}
}

Mapping the Name property for the http user context.

The User.Identity.Name property can be matched from any claim using the TokenValidationParameters. If the default value is not returned, then you need to map this explicitly.

options.TokenValidationParameters = new TokenValidationParameters
{
  NameClaimType = "email", 
  // RoleClaimType = "role"
};

ASP.NET Core also does some magic mapping as a default. Some claims are removed, and some are added. If you want to take control of this, you can turn this off as follows:

JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();

Links:

https://openid.net/specs/openid-connect-core-1_0.html

https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims

https://docs.microsoft.com/en-us/aspnet/core/security/?view=aspnetcore-3.0

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

https://docs.microsoft.com/en-us/aspnet/core/razor-pages

Missing Claims in the ASP.NET Core 2 OpenID Connect Handler?

Force ASP.NET Core OpenID Connect client to require MFA

$
0
0

This article shows how an ASP.NET Core Razor Page application which uses OpenID Connect to sign in, can require that users have authenticated using MFA (multi factor authentication).

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

Blogs in this series

To validate the MFA requirement, an IAuthorizationRequirement requirement is created. This will be added to the pages using a policy which require MFA.

using Microsoft.AspNetCore.Authorization;
 
namespace AspNetCoreRequireMfaOidc
{
    public class RequireMfa : IAuthorizationRequirement{}
}

An AuthorizationHandler is implemented which will use the amr claim and check that it has the value mfa. The amr is returned in the id_token of a successful authentication and can have many different values as defined in the following specification:

https://tools.ietf.org/html/draft-ietf-oauth-amr-values-04

The specification values can be implemented in a class as static strings.

namespace AspNetCoreRequireMfaOidc
{
    /// <summary>
    /// https://tools.ietf.org/html/draft-ietf-oauth-amr-values-04
    /// </summary>
    public static class Amr
    {
        /// <summary>
        /// Jones, et al.Expires May 17, 2017
        /// Internet-Draft Authentication Method Reference Values November 2016
        /// Facial recognition
        /// </summary>
        public static string Face = "face";

        /// <summary>
        /// Fingerprint biometric
        /// </summary>
        public static string Fpt = "fpt";

        /// <summary>
        /// Use of geolocation information
        /// </summary>
        public static string Geo = "geo";

        /// <summary>
        /// Proof-of-possession(PoP) of a hardware-secured key.See
        /// Appendix C of[RFC4211] for a discussion on PoP.
        /// </summary>
        public static string Hwk = "hwk";

        /// <summary>
        /// Iris scan biometric
        /// </summary>
        public static string Iris = "iris";

        /// <summary>
        /// Knowledge-based authentication [NIST.800-63-2] [ISO29115]
        /// </summary>
        public static string Kba = "kba";

        /// <summary>
        /// Multiple-channel authentication.  The authentication involves
        /// communication over more than one distinct communication channel.
        /// For instance, a multiple-channel authentication might involve both
        /// entering information into a workstation's browser and providing
        /// information on a telephone call to a pre-registered number.
        /// </summary>
        public static string Mca = "mca";

        /// <summary>
        /// Multiple-factor authentication [NIST.800-63-2]  [ISO29115].  When 
        /// this is present, specific authentication methods used may also be
        /// included.
        /// </summary>
        public static string Mfa = "mfa";

        /// <summary>
        /// One-time password.  One-time password specifications that this
        /// authentication method applies to include[RFC4226] and[RFC6238].
        /// </summary>
        public static string Otp = "otp";

        /// <summary>
        /// Personal Identification Number or pattern (not restricted to
        /// containing only numbers) that a user enters to unlock a key on the
        /// device.This mechanism should have a way to deter an attacker
        /// from obtaining the PIN by trying repeated guesses.
        /// </summary>
        public static string Pin = "pin";

        /// <summary>
        /// Password-based authentication
        /// </summary>
        public static string Pwd = "pwd";

        /// <summary>
        /// Risk-based authentication [JECM]
        /// </summary>
        public static string Rba = "rba";

        /// <summary>
        /// Retina scan biometric Jones, et al.Expires May 17, 2017
        /// Internet-Draft Authentication Method Reference Values November 2016
        /// </summary>
        public static string Retina = "retina";

        /// <summary>
        /// Smart card
        /// </summary>
        public static string Sc = "sc";

        /// <summary>
        /// Confirmation using SMS message to the user at a registered number
        /// </summary>
        public static string Sms = "sms";

        /// <summary>
        /// Proof-of-possession(PoP) of a software-secured key.See
        /// Appendix C of[RFC4211] for a discussion on PoP.
        /// </summary>
        public static string Swk = "swk";

        /// <summary>
        /// Confirmation by telephone call to the user at a registered number
        /// </summary>
        public static string Tel = "tel";

        /// <summary>
        /// User presence test
        /// </summary>
        public static string User = "user";

        /// <summary>
        /// Voice biometric
        /// </summary>
        public static string Vbm = "vbm";

        /// <summary>
        /// Windows integrated authentication, as described in [MSDN]
        /// </summary>
        public static string Wia = "wia";
    }
}

The AuthorizationHandler then uses the RequireMfa requirement and validates the amr claim. The OpenID Connect server is implemented using IdentityServer4 with ASP.NET Core Identity in this example. When the user logs in using OTP, ie one time passwords, the amr claim is returned with a mfa value. If using a different OpenID Connect server implementation, or a different MFA type, then the amr claim will, or can have a different value, and the code would need to be extended to accept this as well.

using Microsoft.AspNetCore.Authorization;
using System;
using System.Linq;
using System.Threading.Tasks;

namespace AspNetCoreRequireMfaOidc
{
    public class RequireMfaHandler : AuthorizationHandler<RequireMfa>
    {

        protected override Task HandleRequirementAsync(
            AuthorizationHandlerContext context, 
            RequireMfa requirement
        ){
            if (context == null)
                throw new ArgumentNullException(nameof(context));
            if (requirement == null)
                throw new ArgumentNullException(nameof(requirement));

            var amrClaim = context.User.Claims.FirstOrDefault(t => t.Type == "amr");

            if (amrClaim != null && amrClaim.Value == Amr.Mfa)
            {
                context.Succeed(requirement);
            }

            return Task.CompletedTask;
        }
    }
}

In the ConfigureServices method in the Startup class, the AddOpenIdConnect method is used as the default challenge scheme. The authorization handler which is used to check the amr claim is added as to the IoC. A policy is created then which adds the RequireMfa requirement.

public void ConfigureServices(IServiceCollection services)
{
	services.ConfigureApplicationCookie(options =>
	{
		options.Cookie.SecurePolicy = Microsoft.AspNetCore.Http.CookieSecurePolicy.Always;
	});

	services.AddSingleton<IAuthorizationHandler, RequireMfaHandler>();

	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 = "AspNetCoreRequireMfaOidc";
		options.ClientSecret = "AspNetCoreRequireMfaOidcSecret";
		options.ResponseType = "code id_token";
		options.Scope.Add("profile");
		options.Scope.Add("offline_access");
		options.SaveTokens = true;
	});

	services.AddAuthorization(options =>
	{
		options.AddPolicy("RequireMfa", policyIsAdminRequirement =>
		{
			policyIsAdminRequirement.Requirements.Add(new RequireMfa());
		});
	});

	services.AddRazorPages();
}

This policy is then used in the Razor pages as required. This could be added globally for the whole application as well.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;

namespace AspNetCoreRequireMfaOidc.Pages
{
    [Authorize(Policy= "RequireMfa")]
    public class IndexModel : PageModel
    {
        private readonly ILogger<IndexModel> _logger;

        public IndexModel(ILogger<IndexModel> logger)
        {
            _logger = logger;
        }

        public void OnGet()
        {

        }
    }
}

If the user authenticates without MFA, then the amr claim will probably have a pwd value, and the request will not be authorized to access the page. Using the default values, the user will be redirected to the account/AccessDenied page. This can be changed, or you can implement your own custom logic here. In this example, a link is added, so that the valid user can setup MFA for his or her account.

@page
@model AspNetCoreRequireMfaOidc.AccessDeniedModel
@{
    ViewData["Title"] = "AccessDenied";
    Layout = "~/Pages/Shared/_Layout.cshtml";
}

<h1>AccessDenied</h1>

You require MFA to login here

<a href="https://localhost:44352/Manage/TwoFactorAuthentication">Enable MFA</a>

Now only users that do MFA authenticatation can access the page, or website. If different MFA types are used, or 2FA is ok, then the amr claim will have different values, and need to be processed correctly. Different Open ID Connect servers will also return different values for this claim, and might not follow the specification Authentication Method Reference Values.

If we login without MFA, ie just using a password, the amr has the pwd value:

And access is denied:

Or if we login using OTP with Identity:

Links:

https://tools.ietf.org/html/draft-ietf-oauth-amr-values-04

https://openid.net/specs/openid-connect-core-1_0.html

Forcing reauthentication with Azure AD

https://docs.microsoft.com/en-us/azure/active-directory/authentication/concept-mfa-howitworks

https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc

Send MFA signin requirement to OpenID Connect server using ASP.NET Core Identity and IdentityServer4

$
0
0

This post adds the custom ASP.NET Core Identity, IdentityServer4 logic to check for the “acr_values” and react if a client application requests MFA for authentication. The “acr_values” parameter is used to pass the mfa value from the client to the server in the authentication request.

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

Blogs in this series

OpenID Connect ASP.NET Core client

The Razor Page ASP.NET Core Open ID Connnect Client application uses the AddOpenIdConnect method to login to the Open ID Connect server. The “acr_values” parameter is set with the “mfa” value and sent with the authentication request. The OpenIdConnectEvents is used to add this.

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 = "AspNetCoreRequireMfaOidc";
		options.ClientSecret = "AspNetCoreRequireMfaOidcSecret";
		options.ResponseType = "code id_token";
		options.Scope.Add("profile");
		options.Scope.Add("offline_access");
		options.SaveTokens = true;
		options.Events = new OpenIdConnectEvents
		{
			OnRedirectToIdentityProvider = context =>
			{
				context.ProtocolMessage.SetParameter("acr_values", Amr.Mfa);

				return Task.FromResult(0);
			}
		};
	});

	//...
}

OpenID Connect IdentityServer 4 server with ASP.NET Core Identity

On the OpenID Connect server, which is implemented using ASP.NET Core Identity with MVC views, a new view ErrorEnable2FA.cshtml is created, and added.

This view will be displayed if the Identity comes from an application which requires MFA but the user has not activated this in Identity. The view informs the user, and adds a link to activate this.

@{
    ViewData["Title"] = "ErrorEnable2FA";
}

<h1>The client application requires you to have MFA enabled. Enable this, try login again.</h1>

<br />

You can enable MFA to login here:


<br />

<a asp-controller="Manage" asp-action="TwoFactorAuthentication">Enable MFA</a>

In the Login method, the IIdentityServerInteractionService interface implementation _interaction is used to access the Open ID Connnect request parameters. The “acr_values” is accessed using the AcrValues. As the client sent this as mfa, this can then be checked.

If MFA is required, and the user in ASP.NET Core Identity has 2FA enabled, then the login continues. If the user has no 2FA enabled, the user is redirected to the custom view ErrorEnable2FA.cshtml. Then ASP.NET Core Identity signs the user in.

//
// POST: /Account/Login
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login(LoginInputModel model)
{
	var returnUrl = model.ReturnUrl;
	var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
	var requires2Fa = context?.AcrValues.Count(t => t.Contains("mfa")) >= 1;

	var user = await _userManager.FindByNameAsync(model.Email);
	if(user != null && !user.TwoFactorEnabled && requires2Fa)
	{
		return RedirectToAction(nameof(ErrorEnable2FA));
	}

The ExternalLoginCallback works like the local Identity login. The AcrValues property is checked for the “mfa” value and if it is sent, the 2FA is forced before the login completes, ie redirected to the ErrorEnable2FA view.

//
// GET: /Account/ExternalLoginCallback
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> ExternalLoginCallback(string returnUrl = null, string remoteError = null)
{
	var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
	var requires2Fa = context?.AcrValues.Count(t => t.Contains("mfa")) >= 1;

	if (remoteError != null)
	{
		ModelState.AddModelError(string.Empty, _sharedLocalizer["EXTERNAL_PROVIDER_ERROR", remoteError]);
		return View(nameof(Login));
	}
	var info = await _signInManager.GetExternalLoginInfoAsync();

	if (info == null)
	{
		return RedirectToAction(nameof(Login));
	}

	var email = info.Principal.FindFirstValue(ClaimTypes.Email);

	if (!string.IsNullOrEmpty(email))
	{
		var user = await _userManager.FindByNameAsync(email);
		if (user != null && !user.TwoFactorEnabled && requires2Fa)
		{
			return RedirectToAction(nameof(ErrorEnable2FA));
		}
	}

	// Sign in the user with this external login provider if the user already has a login.
	var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false);
	

If the user is already logged in, the client application still validates the “amr” claim, and can setup the MFA then with a link to the ASP.NET Core Identity view.

Links:

https://tools.ietf.org/html/draft-ietf-oauth-amr-values-04

https://openid.net/specs/openid-connect-core-1_0.html

Forcing reauthentication with Azure AD

https://docs.microsoft.com/en-us/azure/active-directory/authentication/concept-mfa-howitworks

https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc

Requiring MFA for Admin Pages in an ASP.NET Core Identity application

$
0
0

This article shows how MFA could be forced on users to access sensitive pages within an ASP.NET Core Identity application. This could be useful for applications where different levels of access exist for the different identities. For example, users might be able to view the profile data using a password login, but an administrator would be required to use MFA to access the admin pages.

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

Blogs in this series

Extending the Login with a MFA claim

The application is setup using ASP.NET Core with Identity and Razor Pages. In this demo, the SQL Server was replaced with SQLite, and the nuget packages were updated. The AddIdentity method is used instead of AddDefaultIdentity one, so we can add an IUserClaimsPrincipalFactory implementation to add claims to the identity after a successful login.

public void ConfigureServices(IServiceCollection services)
{
	services.AddDbContext<ApplicationDbContext>(options =>
		options.UseSqlite(
			Configuration.GetConnectionString("DefaultConnection")));

	//services.AddDefaultIdentity<IdentityUser>(
	//    options => options.SignIn.RequireConfirmedAccount = true)
	//    .AddEntityFrameworkStores<ApplicationDbContext>();

	services.AddIdentity<IdentityUser, IdentityRole>(
		options => options.SignIn.RequireConfirmedAccount = false)
	 .AddEntityFrameworkStores<ApplicationDbContext>()
	 .AddDefaultTokenProviders();

	services.AddSingleton<IEmailSender, EmailSender>();
	services.AddScoped<IUserClaimsPrincipalFactory<IdentityUser>, AdditionalUserClaimsPrincipalFactory>();

	services.AddAuthorization(options =>
	{
		options.AddPolicy("TwoFactorEnabled",
			x => x.RequireClaim("TwoFactorEnabled", "true" )
		) ;
	});

	services.AddRazorPages();
}

The AdditionalUserClaimsPrincipalFactory adds the TwoFactorEnabled claim to the user claims after a successful login. This is only added after a login. The value is read from the database. This is added here because the user should only access the higher protected view, if the identity has logged in with MFA. If the database view was read from the database directly instead of using the claim, it would be possible to access the view without MFA directly after activating the MFA.

using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;

namespace IdentityStandaloneMfa
{
    public class AdditionalUserClaimsPrincipalFactory : UserClaimsPrincipalFactory<IdentityUser, IdentityRole>
    {
        public AdditionalUserClaimsPrincipalFactory( 
            UserManager<IdentityUser> userManager,
            RoleManager<IdentityRole> roleManager, 
            IOptions<IdentityOptions> optionsAccessor) 
            : base(userManager, roleManager, optionsAccessor)
        {
        }

        public async override Task<ClaimsPrincipal> CreateAsync(IdentityUser user)
        {
            var principal = await base.CreateAsync(user);
            var identity = (ClaimsIdentity)principal.Identity;

            var claims = new List<Claim>();

            if (user.TwoFactorEnabled)
            {
                claims.Add(new Claim("TwoFactorEnabled", "true"));
            }
            else
            {
                claims.Add(new Claim("TwoFactorEnabled", "false")); ;
            }

            identity.AddClaims(claims);
            return principal;
        }
    }
}

Because we changed the Identity service setup in the Startup class, the layouts of the Identity need to be updated. Scaffold the Identity pages into the application. Define the layout in the Identity/Account/Manage/_Layout.cshtml file.

@{
    Layout = "/Pages/Shared/_Layout.cshtml";
}

Also add the _layout for all the manage pages from the Identity Pages.

@{
    Layout = "_Layout.cshtml";
}

Validation the MFA requirement in the Admin Page

The admin Razor Page validates that the user has logged in using MFA. In the OnGet method, the Identity is used to access the user claims. The TwoFactorEnabled claim is checked for the value true. If the user has not this claim, the page will redirect to the Enable MFA page. This is possible because the user has logged in already, but without MFA.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace IdentityStandaloneMfa
{
    public class AdminModel : PageModel
    {
        public IActionResult OnGet()
        {
            var claimTwoFactorEnabled = User.Claims.FirstOrDefault(t => t.Type == "TwoFactorEnabled");

            if (claimTwoFactorEnabled != null && "true".Equals(claimTwoFactorEnabled.Value))
            {
                // You logged in with MFA, do the admin stuff
            }
            else
            {
                return Redirect("/Identity/Account/Manage/TwoFactorAuthentication");
            }

            return Page();
        }
    }
}

UI logic to show hide information about the user login

An Authorization policy was added in the startup which requires the TwoFactorEnabled claim with the value true.

services.AddAuthorization(options =>
{
	options.AddPolicy("TwoFactorEnabled",
		x => x.RequireClaim("TwoFactorEnabled", "true" )
	) ;
});

This policy can then be used in the _Layout view to show or hide the Admin menu with the warning.

@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Identity
@inject SignInManager<IdentityUser> SignInManager
@inject UserManager<IdentityUser> UserManager
@inject IAuthorizationService AuthorizationService

If the identity has logged using MFA, then the Admin menu will be displayed without the warning. If the user has logged without the MFA, the font awesome icon will be displayed, and the tooltip which informs the user, explaining the warning.

@if (SignInManager.IsSignedIn(User))
{
	@if ((AuthorizationService.AuthorizeAsync(User, "TwoFactorEnabled")).Result.Succeeded)
	{
		<li class="nav-item">
			<a class="nav-link text-dark" asp-area="" asp-page="/Admin">Admin</a>
		</li>
	}
	else
	{
		<li class="nav-item">
			<a class="nav-link text-dark" asp-area="" asp-page="/Admin" 
			   id="tooltip-demo"  
			   data-toggle="tooltip" 
			   data-placement="bottom" 
			   title="MFA is NOT enabled. This is required for the Admin Page. If you have activated MFA, then logout, login again.">
				<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
				Admin
			</a>

		</li>
	}
}

If the user logins without MFA , then the warning is displayed.

And when the user clicks the admin link, then the user is redirected to the MFA enable view.

Links:

https://tools.ietf.org/html/draft-ietf-oauth-amr-values-04

https://openid.net/specs/openid-connect-core-1_0.html

Forcing reauthentication with Azure AD

https://docs.microsoft.com/en-us/azure/active-directory/authentication/concept-mfa-howitworks

https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc

Login and use an ASP.NET Core API with Azure AD Auth and user access tokens

$
0
0

In this blog post, Azure AD will be setup and used to authenticate and authorize an ASP.NET core Razor Page application which uses an API from a separate ASP.NET Core MVC project. User access tokens are used to access to API, so that an email can be used in the API. The API is not dependent on the UI project as the access token comes straight from Azure AD token server.

Code: https://github.com/damienbod/AzureAD-Auth-MyUI-with-MyAPI

Setup the APP registrations in Azure

Two Azure AD APP registrations can be created to configure this setup. One registration will be used for the Web API and a second registration is used for the UI application. In this post, the Azure portal is used to this up. The email claim will be added to the access token which is then used in the ASP.NET Core Web API.

Setup the Web API APP registration

In the Azure Active directory, click the App registrations and create a new registration using the New registration button.

Leave all the defaults and Register. We want to only use this inside our tenant.

Click the Expose an API, and add a new scope using Add a scope. We want to use the API for user access tokens.

The Application ID URI needs to be created before the required scope can be added. Save and continue.

Now add the access_as_user scope. set the Admins and users, add the required texts, and Add scope. This scope can be used as “api://–clientId–/access_as_user”

Now we need to add a permission so that the email claim can be added to the access token. Click the Add Permission.

From the Microsoft Graph, Delegated permissions, add the email permission. You can add whatever you require in the access token.

In the Token Configuration add the optional email claim to the access token.

Setup the UI APP registration

Now that the Web API is setup, the user interface client APP registration can be created. An ASP.NET Core Razor Page application will be used and this will the access the API. This type of application requires the WEB setup.

Create a new registration for the UI. set the redirect URL to match your application. Click Register

In the Authentication blade, define a Logout URL which matches your application and add support for ID Tokens.

In the API permissions add the API registration which was created above. This can be done in the API permissions, Add a permission, My APIs and add.

The ASP.NET Core application requires a secret to access the API. In Certificates & secrets, create a new secret, and save this somewhere for later usage.

Web API implementation

The Web API can now be implemented using ASP.NET Core. An API project was created using the Visual Studio templates.

Now add the Microsoft.Identity.Web Nuget package to the project. This will be used for the Azure AD auth.

Add the AzureAd configuration to the app.settings.json which must match the APP registration created above.

{
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "damienbodhotmail.onmicrosoft.com",
    "TenantId": "7ff95b15-dc21-4ba6-bc92-824856578fc1",
    "ClientId": "98328d53-55ec-4f14-8407-0ca5ff2f2d20"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*"
}

In the Startup class, add the AddProtectedWebApi from the Microsoft.Identity.Web package to the ConfigureServices method. Then switch off the default ASP.NET Core claim mappings, and add an authorization policy to only allowed authorized requests and the access token must contain an email claim.

public void ConfigureServices(IServiceCollection services)
{
	JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();

	services.AddProtectedWebApi(Configuration);

	services.AddControllers(options =>
	{
		var policy = new AuthorizationPolicyBuilder()
			.RequireAuthenticatedUser()
			.RequireClaim("email")
			.Build();
		options.Filters.Add(new AuthorizeFilter(policy));
	});
}

Add the UseAuthentication and the UseAuthorization middleware in the correct order.

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
	if (env.IsDevelopment())
	{
		app.UseDeveloperExceptionPage();
	}

	app.UseHttpsRedirection();

	app.UseRouting();

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

	app.UseEndpoints(endpoints =>
	{
		endpoints.MapControllers();
	});
}

Razor Page UI implementation

The User interfaxe application is implemented using a ASP.NET Core razor page application. This again was created using the Visual Studio templates.
Add the Microsoft.Identity.Web nuget package and also the Microsoft.Identity.Web.UI package to the UI project.

Add the AzureAd configuration to the app.settings.json and also the API url and the scope to use this. These settings must match what was configured in the portal for the UI registration.

{
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "damienbodhotmail.onmicrosoft.com",
    "TenantId": "7ff95b15-dc21-4ba6-bc92-824856578fc1",
    "ClientId": "64ecb044-417b-4892-83d4-5c03e8c977b9",
    "CallbackPath": "/signin-oidc",
    "SignedOutCallbackPath ": "/signout-callback-oidc"
  },
  "CallApi": {
    "ScopeForAccessToken": "api://98328d53-55ec-4f14-8407-0ca5ff2f2d20/access_as_user",
    "ApiBaseAddress": "https://localhost:44390"

  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*"
}

Add the secret to the project using the secrets manager. This is also added in the AzureAd Json object.

{
  "AzureAd": {
    "ClientSecret": "your secret.."
  }
}

Add an AddSignIn and an AddWebAppCallsProtectedWebApi method to login the UI in Azure, and to configure the API for use. Add the authorization as required for the UI app. The AddMicrosoftIdentityUI is required for the UI views.

public void ConfigureServices(IServiceCollection services)
{
	services.AddTransient<ApiService>();
	services.AddHttpClient();

	services.AddOptions();

	services.AddSignIn(Configuration);
	services.AddWebAppCallsProtectedWebApi(
		Configuration, new string[] 
		{ 
			Configuration["CallApi:ScopeForAccessToken"] 
		}).AddInMemoryTokenCaches();

	services.AddRazorPages().AddMvcOptions(options =>
	{
		var policy = new AuthorizationPolicyBuilder()
			.RequireAuthenticatedUser()
			.Build();
		options.Filters.Add(new AuthorizeFilter(policy));
	}).AddMicrosoftIdentityUI();
}

Add the UseAuthentication and the UseAuthorization middleware in the correct order.

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
	if (env.IsDevelopment())
	{
		app.UseDeveloperExceptionPage();
	}
	else
	{
		app.UseExceptionHandler("/Error");
		app.UseHsts();
	}

	app.UseHttpsRedirection();
	app.UseStaticFiles();

	app.UseRouting();

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

	app.UseEndpoints(endpoints =>
	{
		endpoints.MapRazorPages();
		endpoints.MapControllers();
	});
}

Implement the API service as required. The ITokenAcquisition interface is used to get the access token, so that the API can be used. The required scope for the API is read from the configuration.

using Microsoft.Extensions.Configuration;
using Microsoft.Identity.Web;
using Newtonsoft.Json.Linq;
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;

namespace MyServerRenderedPortal
{
    public class ApiService
    {
        private readonly IHttpClientFactory _clientFactory;
        private readonly ITokenAcquisition _tokenAcquisition;
        private readonly IConfiguration _configuration;

        public ApiService(IHttpClientFactory clientFactory, 
            ITokenAcquisition tokenAcquisition, 
            IConfiguration configuration)
        {
            _clientFactory = clientFactory;
            _tokenAcquisition = tokenAcquisition;
            _configuration = configuration;
        }

        public async Task<JArray> GetApiDataAsync()
        {
            try
            {
                var client = _clientFactory.CreateClient();

                var scope = _configuration["CallApi:ScopeForAccessToken"];
                var accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync(new[] { scope });

                client.BaseAddress = new Uri(_configuration["CallApi:ApiBaseAddress"]);
                client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
                client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
       
                var response = await client.GetAsync("weatherforecast");
                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}");
            }
            catch (Exception e)
            {
                throw new ApplicationException($"Exception {e}");
            }
        }
    }
}

Use the API service as required in the razor pages.

using Microsoft.AspNetCore.Mvc.RazorPages;
using Newtonsoft.Json.Linq;
using System.Threading.Tasks;

namespace MyServerRenderedPortal.Pages
{
    public class CallApiModel : PageModel
    {
        private readonly ApiService _apiService;

        public JArray DataFromApi { get; set; }
        public CallApiModel(ApiService apiService)
        {
            _apiService = apiService;
        }

        public async Task OnGetAsync()
        {
            DataFromApi = await _apiService.GetApiDataAsync();
        }
    }
}

The login and the logout need to be added for the application which uses the Microsoft.Identity.Web.UI package.

The _LoginPartial.cshtml can be implemented, and this uses the UI package which added the MicrosoftIdentity area and the view implementation.


<ul class="navbar-nav">
@if (User.Identity.IsAuthenticated)
{
        <li class="nav-item">
            <span class="navbar-text text-dark">Hello @User.Identity.Name!</span>
        </li>
        <li class="nav-item">
            <a class="nav-link text-dark" asp-area="MicrosoftIdentity" asp-controller="Account" asp-action="SignOut">Sign out</a>
        </li>
}
else
{
        <li class="nav-item">
            <a class="nav-link text-dark" asp-area="MicrosoftIdentity" asp-controller="Account" asp-action="SignIn">Sign in</a>
        </li>
}
</ul>

Now you can start both applications, and if everything is configured correctly, the UI project can login, and use the API in a secure way.

Links:

https://github.com/AzureAD/microsoft-identity-web

https://github.com/Azure-Samples/active-directory-aspnetcore-webapp-openidconnect-v2

https://jwt.io/

Retry Error Handling for Activities and Orchestrations in Azure Durable Functions

$
0
0

Azure Durable Functions provides a rich set of Error Handling APIs. This post shows how Activities or Sub-Orchestrations can be re-run with the different retry options.

Activities in a workflow can call an API or run a code flow which might fail due to connection problems, network timeouts or other similar problems. If it was run a second time, it might succeed, and the flow could then complete successfully. For this, a retry Error Handling can be implemented. The HttpClient in .NET Core can implement this by using Polly. Azure Durable functions supports this directly without requiring extra Nuget packages. This can also be used to retry whole sub-orchestrations and not just single Http API calls.

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

Posts in this series

Retrying Azure Durable Function Activities.

The IDurableOrchestrationContext interface provides the Retry Error Handling APIs in Azure Durable Functions. This interface is added as a parameter in the Azure Function using the OrchestrationTrigger.

[FunctionName(Constants.MyOrchestration)]
public async Task<MyOrchestrationDto> RunOrchestrator(
[OrchestrationTrigger] IDurableOrchestrationContext context,
ILogger log)
{

The RetryOptions class provides the configuration for the different Retry calls. This can be used to set max timeouts, a fall off for retrying, or whatever configuration you might require.

The CallActivityWithRetryAsync method can then be called and if the Activity being called fails, the retry options will be used to re-run the activity until the max has be reached or it succeeds. If the activity continues to throw exceptions, the Durable function will complete with a Failed status, unless otherwise set.

var retryOptions = new RetryOptions(
	firstRetryInterval: TimeSpan.FromSeconds(3),
	maxNumberOfAttempts: 5)
{
	BackoffCoefficient = 1.5
};

var myActivityThreeResult = 
	await context.CallActivityWithRetryAsync<string> (
		Constants.MyActivityThree, 
		retryOptions, 
		context.GetInput<string>()
	);

Retrying Azure Durable Function Sub-Orchestrations.

Polly provides the same functionality when calling APIs. What’s nice about retry in Azure Durable Functions is that the Retry Error Handling can be applied to complete Orchestrations. If any part of the sub-orchestration fails, the whole thing can be re-run using this error handling. This can be implemented using the CallSubOrchestratorWithRetryAsync method.

var mySubOrchestrationDto = 
	await context.CallSubOrchestratorWithRetryAsync<MySubOrchestrationDto> (
		Constants.MySecondOrchestration, 
		retryOptions, 
		myActivityOne
	);

If we add an Exception one on the activities, we can see the retry APIs running and view the status of the orchestration running. (You need to set the showhistory=true)


Links

https://github.com/scale-tone/DurableFunctionsMonitor

https://www.npmjs.com/package/azure-functions-core-tools

https://damienbod.com/2018/12/23/using-azure-key-vault-with-asp-net-core-and-azure-app-services/

https://docs.microsoft.com/en-us/azure/azure-functions/functions-how-to-use-azure-function-app-settings

https://docs.microsoft.com/en-us/azure/azure-functions/durable/

https://github.com/Azure/azure-functions-durable-extension

https://damienbod.com/2019/03/14/running-local-azure-functions-in-visual-studio-with-https/

Microsoft Azure Storage Explorer

Microsoft Azure Storage Emulator

Install the Azure Functions Core Tools

NodeJS

Azure CLI

Azure SDK

Visual Studio zure development extensions

https://docs.microsoft.com/en-us/azure/azure-functions/durable/durable-functions-error-handling?tabs=csharp


Implement a Web APP and an ASP.NET Core Secure API using Azure AD which delegates to a second API

$
0
0

This article shows how an ASP.NET Core Web application can authenticate and access a downstream API using user access tokens and delegate to another API in Azure AD also using user access tokens. Microsoft.Identity.Web is used in all three applications to acquire the tokens for the Web API and the access tokens for the two APIs.

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

Setup and App registrations

The applications are setup as follows.

The applications implement the OAuth 2.0 On-Behalf-Of flow (OBO) and is made easy be using the Microsoft.Identity.Web Nuget packages.

The three applications require App registrations. The first Azure App registration exposes an API using the access_as_user scope. Nothing more is required here. This is the API at the end of the chain.

The API in the middle requires the API permission from the previously created App registration and exposes its own API, again the access_as_user scope. The Web API requires a secret to get the delegated access token and so a client secret is configured in this App registration. (Or a client certificate).

The API permissions is setup to use the scope from the other API.

And it exposes it’s own access_as_user scope.

The Web App requires a Web setup with a client secret (or client certificate) and the API permission from the middle API is added here.

Web Application which calls the first API

The Web APP with the UI interaction uses two Nuget packages, Microsoft.Identity.Web and Microsoft.Identity.Web.UI to implement the authentication and the authorization client for the API. The application is setup to acquire an access token using the EnableTokenAcquisitionToCallDownstreamApi method with the scope from the User API One.

public void ConfigureServices(IServiceCollection services)
{
	services.AddTransient<UserApiOneService>();
	services.AddHttpClient();

	services.AddOptions();

	string[] initialScopes = Configuration.GetValue<string>(
		"UserApiOne:ScopeForAccessToken")?.Split(' ');

	services.AddMicrosoftIdentityWebAppAuthentication(Configuration)
		.EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
		.AddInMemoryTokenCaches();

	services.AddRazorPages().AddMvcOptions(options =>
	{
		var policy = new AuthorizationPolicyBuilder()
			.RequireAuthenticatedUser()
			.Build();
		options.Filters.Add(new AuthorizeFilter(policy));
	}).AddMicrosoftIdentityUI();
}

The two nuget packages are added to the csproj file.

<PackageReference Include="Microsoft.Identity.Web" Version="1.2.0" />
<PackageReference Include="Microsoft.Identity.Web.UI" Version="1.2.0" />

The configuration is setup to use the data for the applicaitons defined in the APP registrations. The scope matches the scope from the User API One.

{
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "damienbodhotmail.onmicrosoft.com",
    "TenantId": "7ff95b15-dc21-4ba6-bc92-824856578fc1",
    "ClientId": "46d2f651-813a-4b5c-8a43-63abcb4f692c",
    "CallbackPath": "/signin-oidc",
    "SignedOutCallbackPath ": "/signout-callback-oidc"
  },
  "UserApiOne": {
    // UserApiOne
    "ScopeForAccessToken": "api://b2a09168-54e2-4bc4-af92-a710a64ef1fa/access_as_user",
    "ApiBaseAddress": "https://localhost:44395"
  },

}

The API client implementation uses the ITokenAcquisition to get the access token for the identity and access the API.

using Microsoft.Extensions.Configuration;
using Microsoft.Identity.Web;
using Newtonsoft.Json.Linq;
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;

namespace WebAppUserApis
{
    public class UserApiOneService
    {
        private readonly IHttpClientFactory _clientFactory;
        private readonly ITokenAcquisition _tokenAcquisition;
        private readonly IConfiguration _configuration;

        public UserApiOneService(IHttpClientFactory clientFactory, 
            ITokenAcquisition tokenAcquisition, 
            IConfiguration configuration)
        {
            _clientFactory = clientFactory;
            _tokenAcquisition = tokenAcquisition;
            _configuration = configuration;
        }

        public async Task<JArray> GetApiDataAsync()
        {
            try
            {
                var client = _clientFactory.CreateClient();

                var scope = _configuration["UserApiOne:ScopeForAccessToken"];
                var accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync(new[] { scope });

                client.BaseAddress = new Uri(_configuration["UserApiOne:ApiBaseAddress"]);
                client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
                client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
       
                var response = await client.GetAsync("weatherforecast");
                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}");
            }
            catch (Exception e)
            {
                throw new ApplicationException($"Exception {e}");
            }
        }
    }
}

The Web App requires a user secret to access and authenticate. This could also be done using a client certificate. A client secret is used in this example and this must match the secret setup in the Web App registration.

{
  "AzureAd": {
    "ClientSecret": "--your secret for WebApp App Registration--" 
  }
}

API which calls the second API

The UI facing API uses a second API for separate data. The second API is also a user access token API and uses delegated tokens to access the data it protects. The API is not used from the UI application. When the access token from the the UI application is used to access the first API, it uses this to get another token to access the access token. This is all setup in the Startup class of the UI facing API. The AddMicrosoftIdentityWebApiAuthentication method is used to setup the API and it enables token acquisition for the second API. This is very simple when using Microsoft.Identity.Web.

public void ConfigureServices(IServiceCollection services)
{
	services.AddTransient<UserApiTwoService>();
	services.AddHttpClient();

	services.AddOptions();

	JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
	// IdentityModelEventSource.ShowPII = true;
	// JwtSecurityTokenHandler.DefaultMapInboundClaims = false;

	services.AddMicrosoftIdentityWebApiAuthentication(Configuration)
		.EnableTokenAcquisitionToCallDownstreamApi()
		.AddInMemoryTokenCaches();

	services.AddControllers(options =>
	{
		var policy = new AuthorizationPolicyBuilder()
			.RequireAuthenticatedUser()
		   // .RequireClaim("email")
			.Build();
		options.Filters.Add(new AuthorizeFilter(policy));
	});
}

The app.settings are configured to use the Azure AD API registration and the scope for the second application.

{
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "damienbodhotmail.onmicrosoft.com",
    "TenantId": "7ff95b15-dc21-4ba6-bc92-824856578fc1",
    "ClientId": "b2a09168-54e2-4bc4-af92-a710a64ef1fa"
  },
  "UserApiTwo": {
    "ScopeForAccessToken": "api://72286b8d-5010-4632-9cea-e69e565a5517/access_as_user",
    "ApiBaseAddress": "https://localhost:44396"
  },

}

The UserApiTwoService gets an access token for the API two scope and this is used to access the Web API controllers to return the data.

using Microsoft.Extensions.Configuration;
using Microsoft.Identity.Web;
using Newtonsoft.Json.Linq;
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;

namespace UserApiOne
{
    public class UserApiTwoService
    {
        private readonly IHttpClientFactory _clientFactory;
        private readonly ITokenAcquisition _tokenAcquisition;
        private readonly IConfiguration _configuration;

        public UserApiTwoService(IHttpClientFactory clientFactory, 
            ITokenAcquisition tokenAcquisition, 
            IConfiguration configuration)
        {
            _clientFactory = clientFactory;
            _tokenAcquisition = tokenAcquisition;
            _configuration = configuration;
        }

        public async Task<JArray> GetApiDataAsync()
        {
            try
            {
                var client = _clientFactory.CreateClient();

                var scope = _configuration["UserApiTwo:ScopeForAccessToken"];
                var accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync(new[] { scope });

                client.BaseAddress = new Uri(_configuration["UserApiTwo:ApiBaseAddress"]);
                client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
                client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
       
                var response = await client.GetAsync("weatherforecast");
                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}");
            }
            catch (Exception e)
            {
                throw new ApplicationException($"Exception {e}");
            }
        }
    }
}

The get an access token for the second API, a client secret or a client certificate is required. The client second is used and this is defined in the first Web API. This can be added to your user secrets or an Azure Key Vault.

{
  "AzureAd": {
    "ClientSecret": "--your secret for UserApiOne  App Registration--" 
  }
}

Second API

The API two is configured in the Startup class to require Azure AD delegrated access tokens. The AddMicrosoftIdentityWebApiAuthentication method is used with no extra configuration. Scopes and roles should be validated as well. This can be done her, or in policies or using the helper methods from the Azure AD Microsoft.Identity.Web packages.

public void ConfigureServices(IServiceCollection services)
{
	JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
	// IdentityModelEventSource.ShowPII = true;
	// JwtSecurityTokenHandler.DefaultMapInboundClaims = false;

	services.AddMicrosoftIdentityWebApiAuthentication(Configuration);

	services.AddControllers(options =>
	{
		var policy = new AuthorizationPolicyBuilder()
			.RequireAuthenticatedUser()
		   // .RequireClaim("email") 
			.Build();
		options.Filters.Add(new AuthorizeFilter(policy));
	});
}

The Azure AD configuration in the app.settings are standard like in the documentation.

{
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "damienbodhotmail.onmicrosoft.com",
    "TenantId": "7ff95b15-dc21-4ba6-bc92-824856578fc1",
    "ClientId": "72286b8d-5010-4632-9cea-e69e565a5517"
  },

}

The VerifyUserHasAnyAcceptedScope can be used to validate a required scope for the delegated access token.

[HttpGet]
public IEnumerable<WeatherForecast> Get()
{
	string[] scopeRequiredByApi = new string[] { "access_as_user" };
	HttpContext.VerifyUserHasAnyAcceptedScope(scopeRequiredByApi);

	// ...
}

When the applications are run, the UI web application authenticates and gets an access token for Web API one. Web API one authorizes the access token and gets an access token for Web API two. Web API two authorizes the access token and returns the data. Web API one gets the data from Web API two and then returns data to the Web App. The full request chain works and uses user access tokens without making the second aPI availoble to the UI application.

Links

https://docs.microsoft.com/en-us/azure/active-directory/manage-apps/howto-saml-token-encryption

Authentication and the Azure SDK

https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow#second-case-access-token-request-with-a-certificate

https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/wiki/Client-credential-flows

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

https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication

https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens

https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/wiki/Client-Assertions

https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow

https://github.com/AzureAD/microsoft-identity-web/wiki/Using-certificates#describing-client-certificates-to-use-by-configuration

API Security with OAuth2 and OpenID Connect in Depth with Kevin Dockx, August 2020

https://www.scottbrady91.com/OAuth/Removing-Shared-Secrets-for-OAuth-Client-Authentication

https://github.com/KevinDockx/ApiSecurityInDepth

https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/wiki

https://docs.microsoft.com/en-us/azure/active-directory/develop/scenario-protected-web-api-verification-scope-app-roles

Securing an ASP.NET Core API which uses multiple access tokens

$
0
0

This post shows how an ASP.NET Core API can authorize API calls which use different access tokens from different identity providers or different access tokens from the same identity provider but created for different clients and containing different claims. The access tokens are validated using JWT Bearer authentication as well as an authorization policy which can validate the specific claims in the access tokens.

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

The ConfigureServices method adds the authentication services using the AddAuthentication method. Two schemes are added, one for each access token. JWT Bearer tokens are used and the Authority and the Audience properties are used to define the auth. If introspection is used, you would define a secret here as well.

The MyApiHandler is added as a service. This provides a way to fulfil the MyApiRequirement which is used in the policy MyPolicy.

Swagger services are added with support for JWT Bearer to make it easier to test.

public void ConfigureServices(IServiceCollection services)
{
	services.AddSingleton<IAuthorizationHandler, MyApiHandler>();

	services.AddAuthentication(
	    IdentityServerAuthenticationDefaults.AuthenticationScheme)
		.AddJwtBearer("SchemeStsA", options =>
		{
			options.Audience = "ProtectedApiResourceA";
			options.Authority = "https://localhost:44318";
		})
		.AddJwtBearer("SchemeStsB", options =>
		{
			options.Audience = "ProtectedApiResourceB";
			options.Authority = "https://localhost:44367";
		});

	services.AddAuthorization(options =>
	{
		options.DefaultPolicy = new AuthorizationPolicyBuilder()
			.RequireAuthenticatedUser()
			.AddAuthenticationSchemes("SchemeStsA", "SchemeStsB")
			.Build();

		options.AddPolicy("MyPolicy", policy =>
		{
			policy.AddRequirements(new MyApiRequirement());
		});
	});

	services.AddControllers();

	services.AddSwaggerGen(c =>
	{
		// add JWT Authentication
		var securityScheme = new OpenApiSecurityScheme
		{
			Name = "JWT Authentication",
			Description = "Enter JWT Bearer token **_only_**",
			In = ParameterLocation.Header,
			Type = SecuritySchemeType.Http,
			Scheme = "bearer", // must be lower case
			BearerFormat = "JWT",
			Reference = new OpenApiReference
			{
				Id = JwtBearerDefaults.AuthenticationScheme,
				Type = ReferenceType.SecurityScheme
			}
		};
		c.AddSecurityDefinition(securityScheme.Reference.Id, securityScheme);
		c.AddSecurityRequirement(new OpenApiSecurityRequirement
		{
			{securityScheme, new string[] { }}
		});

		c.SwaggerDoc("v1", new OpenApiInfo
		{
			Title = "An API ",
			Version = "v1",
			Description = "An API",
			Contact = new OpenApiContact
			{
				Name = "damienbod",
				Email = string.Empty,
				Url = new Uri("https://damienbod.com/"),
			},
		});
	});
}

The Configure method adds the support for Swagger with the JWT Bearer auth UI and the standard middleware setup like the templates.

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
	// IdentityModelEventSource.ShowPII = true;
	JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();

	if (env.IsDevelopment())
	{
		app.UseDeveloperExceptionPage();
	}
	else
	{
		app.UseExceptionHandler("/Error");
		app.UseHsts();
	}

	app.UseSwagger();
	app.UseSwaggerUI(c =>
	{
		c.SwaggerEndpoint("/swagger/v1/swagger.json", "Service API One");
		c.RoutePrefix = string.Empty;
	});

	app.UseStaticFiles();
	app.UseRouting();
	app.UseAuthentication();
	app.UseAuthorization();

	app.UseEndpoints(endpoints =>
	{
		endpoints.MapControllers();
	});
}

A new class MyApiRequirement was created which implements the IAuthorizationRequirement interface.

using Microsoft.AspNetCore.Authorization;

namespace WebApi
{
    public class MyApiRequirement : IAuthorizationRequirement
    {
    }
}

The MyApiHandler implements the AuthorizationHandler with the requirement MyApiRequirement. This is used to implement the logic to fulfil the requirement MyApiRequirement. In this demo, depending on the client_id claim in the access token, a different scope is required to fulfil the requirement. Any logic can be used here depending on your business requirements.

using Microsoft.AspNetCore.Authorization;
using System;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;

namespace WebApi
{
    public class MyApiHandler : AuthorizationHandler<MyApiRequirement>
    {
        protected override Task HandleRequirementAsync(
           AuthorizationHandlerContext context, MyApiRequirement requirement)
        {
            if (context == null)
                throw new ArgumentNullException(nameof(context));
            if (requirement == null)
                throw new ArgumentNullException(nameof(requirement));

            var client_id = context.User.Claims
                 .FirstOrDefault(t => t.Type == "client_id");
            var scope = context.User.Claims
                 .FirstOrDefault(t => t.Type == "scope");

            if (AccessTokenValid(client_id, scope))
            {
                context.Succeed(requirement);
            }

            return Task.CompletedTask;
        }

        private bool AccessTokenValid(Claim client_id, Claim scope)
        {
            if (client_id != null && client_id.Value == "CC_STS_A")
            {
                return StsAScopeAValid(scope);
            }

            if (client_id != null && client_id.Value == "CC_STS_B")
            {
                return StsBScopeBValid(scope);
            }

            return false;
        }

        private bool StsAScopeAValid(Claim scope)
        {
            if (scope != null && scope.Value == "scope_a")
            {
                return true;
            }

            return false;
        }

        private bool StsBScopeBValid(Claim scope)
        {
            if (scope != null && scope.Value == "scope_b")
            {
                return true;
            }

            return false;
        }

    }
}

The policy and the authentication schemes can be used in ASP.NET Core controllers. Every Authorize attribute must succeed, if access is given to the request with the access token calling the API. This is why the single policy was used to implement the different authorization rules for the different access tokens. If this was more complex, it would make sense to have a single controller for each access token type. The allowed schemes can be defined in a comma separated string.

using System.Collections.Generic;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace WebApi.Controllers
{
    [Route("api/[controller]")]
    public class ValuesController : Controller
    {
        [Authorize(AuthenticationSchemes = "SchemeStsA,SchemeStsB", Policy = "MyPolicy")]
        [HttpGet]
        public IEnumerable<string> Get()
        {
            return new string[] { "data 1 from the api", "data 2 from the api" };
        }
    }
}

Getting an access token

In the example, IdentityServer4 is used as the identity provider and the client credential flow is used to get an access token for the APP to APP access. The trusted client uses a shared secret to get the token. OAuth have some RFCs which can improve this and avoid the use of a shared secrets or if all applications are under your control, you could use Azure Key Vault to share the secret which is auto generate in an Azure DevOps pipeline.

private async Task<AccessTokenItem> getApiToken(string api_name, string api_scope, string secret)
{
	try
	{
		var disco = await HttpClientDiscoveryExtensions.GetDiscoveryDocumentAsync(
			_httpClient,
			_authConfigurations.Value.StsServer);

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

		var tokenResponse = await HttpClientTokenRequestExtensions.RequestClientCredentialsTokenAsync(_httpClient, new ClientCredentialsTokenRequest
		{
			Scope = api_scope,
			ClientSecret = secret,
			Address = disco.TokenEndpoint,
			ClientId = api_name
		});

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

		return new AccessTokenItem
		{
			ExpiresIn = DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn),
			AccessToken = tokenResponse.AccessToken
		};

	}
	catch (Exception e)
	{
		_logger.LogError($"Exception {e}");
		throw new ApplicationException($"Exception {e}");
	}
}

Using Postman

Postman can also be used to get an access token for this OAuth client credentials flow.

POST https://localhost:44367/connect/token

scope:scope_b
client_id:CC_STS_B
client_secret:cc_secret
grant_type:client_credentials

This uses the parameters like shown above.

Calling the payload API

The access token can be used to access the payload data. This can be added directly to your Swagger client.

And the request will be sent and the data can returned.

The access token can also be used in C# code to request the data.

public async Task<JArray> GetApiDataAsync()
{
	try
	{
		var client = _clientFactory.CreateClient();

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

		var access_token = await _apiTokenClient.GetApiToken(
			"CC_STS_B",
			"scope_b",
			"cc_secret"
		);

		client.SetBearerToken(access_token);

		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}");
	}
	catch (Exception e)
	{
		throw new ApplicationException($"Exception {e}");
	}
}

Links

https://docs.microsoft.com/en-us/aspnet/core/security/authorization/introduction

Using multiple APIs in Angular and ASP.NET Core with Azure AD authentication

$
0
0

This article shows how an Angular application could be used to access many APIs in a secure way. An API is created specifically for the Angular UI and the further APIs can only be access from the trusted backend which is under our control.

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

Setup

The applications are setup so that the Angular application only accesses a single API which was created specifically for the UI. All other APIs are deployed in a trusted zone and require a secret or a certificate to use the service. With this, only a single access token leaves the secure zone and there is no need to handle multiple tokens in an unsecure browser. Secondly the API calls can be optimized so that the network loads which come with so many SPAs can be improved. The API is our gateway to the data required by the UI.

This is very like the backend for frontend application architecture (BFF) which is more secure than this setup because the security for the UI is also implemented in the trusted backend for the UI, ie (no access tokens in the browser storage, no refresh/renew in the browser). The advantage here is the structure is easier to setup with existing UI teams, backend teams and the technology stacks like ASP.NET Core, Angular support this structure better.

In this demo, we will be implementing the SPA in Angular but this could easily be switched out for a Blazor, React or a Vue.js UI. The Authentication is implemented using Azure AD.

The APIs

The API which was created for the UI uses Microsoft.Identity.Web to implement the Azure AD security. All API HTTP requests to this service require a valid access token which was created for this service. In the Startup class, the AddMicrosoftIdentityWebApiAuthentication is used to add the auth services for Azure AD to the application. The AddHttpClient is used so that the IHttpClientFactory can be used to access the downstream APIs. The different API client services are added as scoped services. CORS is setup so the Angular application can access the API. The CORS setup for the UI API calls should be configured as strict as possible. An authorize policy is added which validates the azp claim. This value must match the App registration setup for your UI application. If different UIs or different access tokens are allowed, then you would have to change this. An in memory cache is used to store the downstream API access tokens. The API access three different types of downstream APIs, a delegated API which uses the OBO flow to get a token, an application API, which uses the client credentials flow and the default scope and a graph API delegated API which uses the OBO flow again.


public void ConfigureServices(IServiceCollection services)
{
	services.AddHttpClient();
	services.AddOptions();

	JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
	IdentityModelEventSource.ShowPII = true;
	JwtSecurityTokenHandler.DefaultMapInboundClaims = false;

	services.AddCors(options =>
	{
		options.AddPolicy("AllowAllOrigins",
			builder =>
			{
				builder
					.AllowCredentials()
					.WithOrigins(
						"https://localhost:4200")
					.SetIsOriginAllowedToAllowWildcardSubdomains()
					.AllowAnyHeader()
					.AllowAnyMethod();
			});
	});

	services.AddScoped<GraphApiClientService>();
	services.AddScoped<ServiceApiClientService>();
	services.AddScoped<UserApiClientService>();

	services.AddMicrosoftIdentityWebApiAuthentication(Configuration)
		 .EnableTokenAcquisitionToCallDownstreamApi()
		 .AddInMemoryTokenCaches();

	services.AddControllers(options =>
	{
		var policy = new AuthorizationPolicyBuilder()
			.RequireAuthenticatedUser()
			.Build();
		options.Filters.Add(new AuthorizeFilter(policy));
	});

	services.AddAuthorization(options =>
	{
		options.AddPolicy("ValidateAccessTokenPolicy", validateAccessTokenPolicy =>
		{
			// Validate ClientId from token
			// only accept tokens issued ....
			validateAccessTokenPolicy.RequireClaim("azp", "ad6b0351-92b4-4ee9-ac8d-3e76e5fd1c67");
		});
	});

	// .... + swagger
}

The API using no extra services

The API which returns data directly uses the correct JwtBearerDefaults.AuthenticationScheme scheme to validate the token and requires that the ValidateAccessTokenPolicy succeeds the authorize checks. Then the data is returned. This is pretty straight forward.

using System.Collections.Generic;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace ApiWithMutlipleApis.Controllers
{
    [Authorize(Policy = "ValidateAccessTokenPolicy", 
        AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
    [ApiController]
    [Route("[controller]")]
    public class DirectApiController : ControllerBase
    {
        [HttpGet]
        public IEnumerable<string> Get()
        {
            return new List<string> { "some data", "more data", "loads of data" };
        }
    }
}

API which uses the Application API

The ServiceApiCallsController implements the API will uses the ServiceApiClientService to request data from the application API. This is an APP to APP request and cannot be used from any type of SPA because the API can only be accessed by using a secret or a certificate. SPAs cannot keep or use secrets. Using it from our trusted web API solves this and it can use the data as needed or allowed.

using System.Collections.Generic;
using System.Threading.Tasks;
using ApiWithMutlipleApis.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace ApiWithMutlipleApis.Controllers
{
    [Authorize(Policy = "ValidateAccessTokenPolicy", AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
    [ApiController]
    [Route("[controller]")]
    public class ServiceApiCallsController : ControllerBase
    {
        private ServiceApiClientService _serviceApiClientService;

        public ServiceApiCallsController(ServiceApiClientService serviceApiClientService)
        {
            _serviceApiClientService = serviceApiClientService;
        }

        [HttpGet]
        public async Task<IEnumerable<string>> Get()
        {
            return await _serviceApiClientService.GetApiDataAsync();
        }
    }
}

The ServiceApiClientService uses the ITokenAcquisition to get an access token for the .default scope of the API. The access_as_application scope is added to the Azure App Registration for this API. The access token is requested using the OAuth client credentials flow. This flow is normal not used for delegated users. This is good if you have some type of global service or application level type of features with no users involved.

using Microsoft.Identity.Web;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Threading.Tasks;

namespace ApiWithMutlipleApis.Services
{
    public class ServiceApiClientService
    {
        private readonly IHttpClientFactory _clientFactory;
        private readonly ITokenAcquisition _tokenAcquisition;

        public ServiceApiClientService(
            ITokenAcquisition tokenAcquisition,
            IHttpClientFactory clientFactory)
        {
            _clientFactory = clientFactory;
            _tokenAcquisition = tokenAcquisition;
        }

        public async Task<IEnumerable<string>> GetApiDataAsync()
        {

            var client = _clientFactory.CreateClient();

            var scope = "api://b178f3a5-7588-492a-924f-72d7887b7e48/.default"; // CC flow access_as_application";
            var accessToken = await _tokenAcquisition.GetAccessTokenForAppAsync(scope);

            client.BaseAddress = new Uri("https://localhost:44324");
            client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
            client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

            var response = await client.GetAsync("ApiForServiceData");
            if (response.IsSuccessStatusCode)
            {
                var data = await JsonSerializer.DeserializeAsync<List<string>>(
                    await response.Content.ReadAsStreamAsync());

                return data;
            }

            throw new Exception("oh no...");
        }
    }
}

API using the delegated API

The DelegatedUserApiCallsController is used to access a downstream API with uses delegated access tokens. This would be more the standard type of request in Azure. The UserApiClientService is used to access the API.

using System.Collections.Generic;
using System.Threading.Tasks;
using ApiWithMutlipleApis.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace ApiWithMutlipleApis.Controllers
{
    [Authorize(Policy = "ValidateAccessTokenPolicy", AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
    [ApiController]
    [Route("[controller]")]
    public class DelegatedUserApiCallsController : ControllerBase
    {
        private UserApiClientService _userApiClientService;

        public DelegatedUserApiCallsController(UserApiClientService userApiClientService)
        {
            _userApiClientService = userApiClientService;
        }

        [HttpGet]
        public async Task<IEnumerable<string>> Get()
        {
            return await _userApiClientService.GetApiDataAsync();
        }
    }
}

The UserApiClientService uses the ITokenAcquisition to get an access token for the access_as_user scope of the API. The access_as_user scope is added to the Azure App Registration for this API. The access token is requested using the On behalf flow (OBO). The access token are added to an in memory cache.

using Microsoft.Identity.Web;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Threading.Tasks;

namespace ApiWithMutlipleApis.Services
{
    public class UserApiClientService
    {
        private readonly IHttpClientFactory _clientFactory;
        private readonly ITokenAcquisition _tokenAcquisition;

        public UserApiClientService(
            ITokenAcquisition tokenAcquisition,
            IHttpClientFactory clientFactory)
        {
            _clientFactory = clientFactory;
            _tokenAcquisition = tokenAcquisition;
        }

        public async Task<IEnumerable<string>> GetApiDataAsync()
        {

            var client = _clientFactory.CreateClient();

            var scopes = new List<string> { "api://b2a09168-54e2-4bc4-af92-a710a64ef1fa/access_as_user" };
            var accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync(scopes);

            client.BaseAddress = new Uri("https://localhost:44395");
            client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
            client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

            var response = await client.GetAsync("ApiForUserData");
            if (response.IsSuccessStatusCode)
            {
                var data = await JsonSerializer.DeserializeAsync<List<string>>(
                    await response.Content.ReadAsStreamAsync());

                return data;
            }

            throw new Exception("oh no...");
        }
    }
}

API using the Graph API

The GraphApiCallsController API is used to access the Microsoft Graph API using the GraphApiClientService. This service uses a delegated access token to access the Microsoft Graph API delegated APIs which have been exposed in the Azure App Registration.

using System.Collections.Generic;
using System.Threading.Tasks;
using ApiWithMutlipleApis.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace ApiWithMutlipleApis.Controllers
{
    [Authorize(Policy = "ValidateAccessTokenPolicy", AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
    [ApiController]
    [Route("[controller]")]
    public class GraphApiCallsController : ControllerBase
    {
        private GraphApiClientService _graphApiClientService;

        public GraphApiCallsController(GraphApiClientService graphApiClientService)
        {
            _graphApiClientService = graphApiClientService;
        }

        [HttpGet]
        public async Task<IEnumerable<string>> Get()
        {
            var userData = await _graphApiClientService.GetGraphApiUser();
            return new List<string> { $"DisplayName: {userData.DisplayName}",
                $"GivenName: {userData.GivenName}", $"AboutMe: {userData.AboutMe}" };
        }
    }
}

The GraphApiClientService uses the ITokenAcquisition to get an access token for required Graph API scopes. Microsoft Graph API has also its own internal auth provider which also implements access token management like the Microsoft.Identity.Web. You could also use it. I use the ITokenAcquisition for token management like the previous two APIs for consistency.

using Microsoft.Graph;
using Microsoft.Identity.Web;
using System;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;

namespace ApiWithMutlipleApis.Services
{
    public class GraphApiClientService
    {
        private readonly ITokenAcquisition _tokenAcquisition;
        private readonly IHttpClientFactory _clientFactory;

        public GraphApiClientService(ITokenAcquisition tokenAcquisition,
            IHttpClientFactory clientFactory)
        {
            _clientFactory = clientFactory;
            _tokenAcquisition = tokenAcquisition;
        }

        public async Task<User> GetGraphApiUser()
        {
            var graphclient = await GetGraphClient(new string[] { "User.ReadBasic.All", "user.read" })
               .ConfigureAwait(false);

            return await graphclient.Me.Request().GetAsync().ConfigureAwait(false);
        }

        public async Task<string> GetGraphApiProfilePhoto()
        {
            try
            {
                var graphclient = await GetGraphClient(new string[] { "User.ReadBasic.All", "user.read" })
               .ConfigureAwait(false);

                var photo = string.Empty;
                // Get user photo
                using (var photoStream = await graphclient.Me.Photo
                    .Content.Request().GetAsync().ConfigureAwait(false))
                {
                    byte[] photoByte = ((MemoryStream)photoStream).ToArray();
                    photo = Convert.ToBase64String(photoByte);
                }

                return photo;
            }
            catch
            {
                return string.Empty;
            }   
        }

       
        private async Task<GraphServiceClient> GetGraphClient(string[] scopes)
        {
            var token = await _tokenAcquisition.GetAccessTokenForUserAsync(
             scopes).ConfigureAwait(false);

            var client = _clientFactory.CreateClient();
            client.BaseAddress = new Uri("https://graph.microsoft.com/beta");
            client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

            GraphServiceClient graphClient = new GraphServiceClient(client)
            {
                AuthenticationProvider = new DelegateAuthenticationProvider(async (requestMessage) =>
                {
                    requestMessage.Headers.Authorization = new AuthenticationHeaderValue("bearer", token);
                })
            };

            graphClient.BaseUrl = "https://graph.microsoft.com/beta";
            return graphClient;
        }
    }
}

In the app.settings.json file, add the Azure AD App registration settings to match the the configuration for this application.

{
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "your domain",
    "TenantId": "your tenant id",
    "ClientId": "your client id"
  }
}

Add the ClientSecret to the user secrets in your application. In a deployed version, you could add this to your Azure Key Vault.

{
  "AzureAd": {
    "ClientSecret": "your app registration secret"
  }
}

The Azure APIs which are used from this API must be exposed here. A client secret is also added to the App registration definition for the API project. Application scopes as well as delegated scopes are exposed here. This client secret is used to access the downstream APIs exposed here. You could also use a certificate instead of a client secret.

The Application API

The application API is very simple to setup. This uses the standard Microsoft.Identity.Web settings for an API. The authorization middleware checks that the azpacr claim has a value of 1 to make sure only a token which used a secret to get the access token can access this API. If using certificates, the value would be 2. The azp is used to validate that the correct Web API requested the access token.

public void ConfigureServices(IServiceCollection services)
{
	JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
	IdentityModelEventSource.ShowPII = true;
	JwtSecurityTokenHandler.DefaultMapInboundClaims = false;

	services.AddSingleton<IAuthorizationHandler, HasServiceApiRoleHandler>();

	services.AddMicrosoftIdentityWebApiAuthentication(Configuration);

	services.AddControllers();

	services.AddAuthorization(options =>
	{
		options.AddPolicy("ValidateAccessTokenPolicy", validateAccessTokenPolicy =>
		{
			validateAccessTokenPolicy.Requirements.Add(new HasServiceApiRoleRequirement());
			
			// Validate id of application for which the token was created
			// In this case the UI application 
			validateAccessTokenPolicy.RequireClaim("azp", "2b50a014-f353-4c10-aace-024f19a55569");

			// only allow tokens which used "Private key JWT Client authentication"
			// // https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens
			// Indicates how the client was authenticated. For a public client, the value is "0". 
			// If client ID and client secret are used, the value is "1". 
			// If a client certificate was used for authentication, the value is "2".
			validateAccessTokenPolicy.RequireClaim("azpacr", "1");
		});
	});

	// add swagger ...

}

The AuthorizationHandler is used to fulfil the requirement HasServiceApiRoleRequirement which the API uses in its policy to authorize the access token. The authorization middlerware validates that the service-api scope claim is present in the access token.

using Microsoft.AspNetCore.Authorization;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;

namespace ServiceApi
{
    public class HasServiceApiRoleHandler : AuthorizationHandler<HasServiceApiRoleRequirement>
    {
        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, HasServiceApiRoleRequirement requirement)
        {
            if (context == null)
                throw new ArgumentNullException(nameof(context));
            if (requirement == null)
                throw new ArgumentNullException(nameof(requirement));

            var roleClaims = context.User.Claims.Where(t => t.Type == "roles");

            if (roleClaims != null && HasServiceApiRole(roleClaims))
            {
                context.Succeed(requirement);
            }

            return Task.CompletedTask;
        }

        private bool HasServiceApiRole(IEnumerable<Claim> roleClaims)
        {
            // we could also validate the "access_as_application" scope
            foreach(var role in roleClaims)
            {
                if("service-api" == role.Value)
                {
                    return true;
                }
            }

            return false;
        }
    }
}

The API uses the Policy ValidateAccessTokenPolicy to authorize the access token.

using System.Collections.Generic;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace ServiceApi.Controllers
{
    [Authorize(Policy = "ValidateAccessTokenPolicy", AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
    [ApiController]
    [Route("[controller]")]
    public class ApiForServiceDataController : ControllerBase
    {
        [HttpGet]
        public IEnumerable<string> Get()
        {
            return new List<string> { "app-app Service API data 1", "service API data 2" };
        }
    }
}

User API for the delegated access

The API which uses the delegated access token which the frontend API got by using the OBO flow, is implemented like in this blog: Implement a Web APP and an ASP.NET Core Secure API using Azure AD which delegates to a second API. Again the azpacr claim is used to check that a client secret was used to get the access token requesting the API.

services.AddAuthorization(options =>
{
	options.AddPolicy("ValidateAccessTokenPolicy", validateAccessTokenPolicy =>
	{
		validateAccessTokenPolicy.RequireClaim("azp", "2b50a014-f353-4c10-aace-024f19a55569");

		validateAccessTokenPolicy.RequireClaim("azpacr", "1");
	});
});

The Angular UI

Code: Angular CLI project

The UI part of the solution is implemented in Angular. The Angular SPA application which runs completely in the browser of the client needs to authenticate and store its tokens somewhere in the browser, usually in the session state. The Angular SPA cannot keep a secret, it is a public client. To authenticate, the application uses an Azure AD public client created using an Azure App Registration. The Azure App Registration is setup to support the OIDC Connect code flow with PKCE and uses a delegated access token for our backend. It has only access to the top API.

Only the single access token is moved around and stored in the public zone. This access token should have a short lifespan and be renewed or refreshed. There are two ways of renewing or refreshing access tokens in a SPA. One way is to silent renew in an Iframe but this is getting blocked now by Safari and Brave and soon other browsers. The second way is to use refresh tokens. This can lead to other security problems, but the risks can be reduced by using best practices like one-time usage and so on. Another way of reducing the risk would be to use the revocation endpoint to invalidate the refresh token, access token but this is not supported yet by Azure AD. Using reference tokens would also help but this is also not supported by Azure AD. For this reason, as little as possible should be implemented in the unsecure browser. Using multiple access tokens in your SPA is not a good idea. To get a second access token, a full UI authenticate is required (silent or in a popup, app redirect) and then the second access token would also be public. We want as few as possible public security parts.

The npm package angular-auth-oidc-client can be used to implement the security flows for the Angular app. Other Angular npm packages also work fine, you can choose the one you like or know best. Add the security lib configuration to the app.module which matches the Azure App Registration for this APP.

We will use an Auth Guard to protect the routes which must be protected. You MUST leave the default route and maybe an error or info route unprotected due to the constraints of the Open ID Connect code flow. The redirect steps of the flow CANNOT be protected with the auth guard. The auth guard is added to the routes.


export function configureAuth(oidcConfigService: OidcConfigService) {
  return () =>
    oidcConfigService.withConfig({
            stsServer: 'https://login.microsoftonline.com/7ff95b15-dc21-4ba6-bc92-824856578fc1/v2.0',
            authWellknownEndpoint: 'https://login.microsoftonline.com/7ff95b15-dc21-4ba6-bc92-824856578fc1/v2.0',
            redirectUrl: window.location.origin,
            clientId: 'ad6b0351-92b4-4ee9-ac8d-3e76e5fd1c67',
            scope: 'openid profile email api://2b50a014-f353-4c10-aace-024f19a55569/access_as_user offline_access',
            responseType: 'code',
            silentRenew: true,
            useRefreshToken: true,
            maxIdTokenIatOffsetAllowedInSeconds: 600,
            issValidationOff: false,
            autoUserinfo: false,
            logLevel: LogLevel.Debug
    });
}

@NgModule({
  declarations: [
    AppComponent,
    HomeComponent,
    NavMenuComponent,
    UnauthorizedComponent,
    DirectApiCallComponent,
    GraphApiCallComponent,
    ApplicationApiCallComponent,
    DelegatedApiCallComponent
  ],
  imports: [
    BrowserModule,
    RouterModule.forRoot([
    { path: '', redirectTo: 'home', pathMatch: 'full' },
    { path: 'home', component: HomeComponent },
    { path: 'directApiCall', component: DirectApiCallComponent, canActivate: [AuthorizationGuard] },
    { path: 'graphApiCall', component: GraphApiCallComponent, canActivate: [AuthorizationGuard] },
    { path: 'applicationApiCall', component: ApplicationApiCallComponent, canActivate: [AuthorizationGuard] },
    { path: 'delegatedApiCall', component: DelegatedApiCallComponent, canActivate: [AuthorizationGuard] },
    { path: 'unauthorized', component: UnauthorizedComponent },
  ], { relativeLinkResolution: 'legacy' }),
    AuthModule.forRoot(),
    HttpClientModule,
  ],
  providers: [
    OidcConfigService,
    {
      provide: APP_INITIALIZER,
      useFactory: configureAuth,
      deps: [OidcConfigService],
      multi: true,
    },
    {
      provide: HTTP_INTERCEPTORS,
      useClass: AuthInterceptor,
      multi: true,
    },
    AuthorizationGuard
  ],
  bootstrap: [AppComponent],
})
export class AppModule {}

The AuthorizationGuard is implemented using the CanActivate. The oidcSecurityService.isAuthenticated$ pipe can be used to check.

import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router';
import { OidcSecurityService } from 'angular-auth-oidc-client';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class AuthorizationGuard implements CanActivate {
    constructor(private oidcSecurityService: OidcSecurityService, private router: Router) {}

    canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
        return this.oidcSecurityService.isAuthenticated$.pipe(
            map((isAuthorized: boolean) => {
                console.log('AuthorizationGuard, canActivate isAuthorized: ' + isAuthorized);

                if (!isAuthorized) {
                    this.router.navigate(['/unauthorized']);
                    return false;
                }

                return true;
            })
        );
    }
}

The angular-auth-oidc-client this.authService.checkAuth() method is called once in the app.component class. This is part of the default route. When the redirect from the security flow calls back or the app is refreshed in the browser, the correct state will be initialized for the APP.

import { Component, OnInit } from '@angular/core';
import { AuthService } from './auth.service';

@Component({
  selector: 'app-root',
  templateUrl: 'app.component.html',
})
export class AppComponent implements OnInit {
  constructor(public authService: AuthService) {}

  ngOnInit() {
    this.authService
      .checkAuth()
      .subscribe((isAuthenticated) =>
        console.log('app authenticated', isAuthenticated)
      );
  }
}

An AuthInterceptor is used to add the access token to the outgoing HTTP calls. The HttpInterceptor is for ALL HTTP requests, so care needs to be taken that the access token is only sent when making an HTTP request to one of the APIs for which the access token was intended for.

import { HttpInterceptor, HttpRequest, HttpHandler } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { AuthService } from './auth.service';

@Injectable()
export class AuthInterceptor implements HttpInterceptor {
  private secureRoutes = ['https://localhost:44390'];

  constructor(private authService: AuthService) {}

  intercept(
    request: HttpRequest<any>,
    next: HttpHandler
  ) {
    if (!this.secureRoutes.find((x) => request.url.startsWith(x))) {
      return next.handle(request);
    }

    const token = this.authService.token;

    if (!token) {
      return next.handle(request);
    }

    request = request.clone({
      headers: request.headers.set('Authorization', 'Bearer ' + token),
    });

    return next.handle(request);
  }
}

The DirectApiCallComponent implements the view uses the HttpClient to get the secure data from the API protected with Azure AD.

import { HttpClient } from '@angular/common/http';
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { finalize } from 'rxjs/operators';
import { AuthService } from '../auth.service';

@Component({
  selector: 'app-direct-api-call',
  templateUrl: 'directApiCall.component.html',
})
export class DirectApiCallComponent implements OnInit {
  userData$: Observable<any>;
  dataFromAzureProtectedApi$: Observable<any>;
  isAuthenticated$: Observable<boolean>;
  httpRequestRunning = false;

  constructor(
    private authService: AuthService,
    private httpClient: HttpClient
  ) {}

  ngOnInit() {
    this.userData$ = this.authService.userData$;
    this.isAuthenticated$ = this.authService.signedIn$;
  }

  callApi() {
    this.httpRequestRunning = true;
    this.dataFromAzureProtectedApi$ = this.httpClient
      .get('https://localhost:44390/DirectApi')
      .pipe(finalize(() => (this.httpRequestRunning = false)));
  }
}

The data is displayed in the template for the Angular component.


<div *ngIf="isAuthenticated$ | async as isAuthenticated">

  <button class="btn btn-primary" type="button" (click)="callApi()" [disabled]="httpRequestRunning">
    <span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" [hidden]="!httpRequestRunning" ></span>
    Request Data
  </button>

  <br/><br/>

  Is Authenticated: {{ isAuthenticated }}

  <br/><br/>

  <div class="card">
    <div class="card-header">Data from direct API</div>
    <div class="card-body">
      <pre>{{ dataFromAzureProtectedApi$ | async | json }}</pre>
    </div>
  </div>

</div>


Now everything is working and the applications can be started and run.

By using ASP.NET Core as a gateway for further APIs or services, it is extremely easy to add further things like Databases, Storage, Azure Service Bus, IoT solutions, or any type of Azure / Cloud service as all have uncomplicated solutions for ASP.NET Core.

The solution could then be further improved by adding network security. A simple VNET could be created and the protected APIs can be made only available inside the VNET. This costs nothing and is simple to implement. You could use Cloudflare as a firewall or Azure Firewall.

In a follow up post to this, I plan to implement authorization using roles and groups.

Links

https://docs.microsoft.com/en-us/azure/active-directory/manage-apps/howto-saml-token-encryption

Authentication and the Azure SDK

https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow#second-case-access-token-request-with-a-certificate

https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/wiki/Client-credential-flows

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

https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication

https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens

https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/wiki/Client-Assertions

https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow

https://github.com/AzureAD/microsoft-identity-web/wiki/Using-certificates#describing-client-certificates-to-use-by-configuration

API Security with OAuth2 and OpenID Connect in Depth with Kevin Dockx, August 2020

https://www.scottbrady91.com/OAuth/Removing-Shared-Secrets-for-OAuth-Client-Authentication

https://github.com/KevinDockx/ApiSecurityInDepth

https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/wiki

https://docs.microsoft.com/en-us/azure/active-directory/develop/scenario-protected-web-api-verification-scope-app-roles

Implement OAUTH Device Code Flow with Azure AD and ASP.NET Core

$
0
0

The post shows how the Device Code flow (RFC 8628) could be implemented in an ASP.NET Core web application which uses Azure AD as an identity provider. An Azure App registration is used to setup the client. This solution would be useful for input constrained devices which have a browser and need to authenticate identities.

Code: Device Code Flow with Azure AD

When or why use this flow?

The OAuth Device code flow is a good solution for authentication when the client has input constraints or only a console. As an example, this solution would work really well on a game console, a TV, industrial machine PC, or a layer 7 gateway.

Create the Azure App Registration

The Azure App registration is setup in the tenant or the directory for Mobile and desktop applications. This is a public client which requires no secret. For testing, the localhost redirect url was added.

The Allow public client flows option is set to yes. The Device code flow is supported in Azure AD with this Azure App registration configuration.

In the API permissions, the required scopes are added. The standard scopes, email, openid and profile are added. These can be added from the Graph settings in the delegated scopes. Only delegated scopes are used for the public client.

Azure AD is now configured and setup to support the OAuth Device code flow, RFC 8628. The clientId and the tenantId are required to configure the client which uses this app registration.

Setup the Device Code flow client

The client can be implemented using different Nuget packages, or even completely implemented yourself. The specifications for this are an RFC and is simple to follow. I would recommend using a client Nuget package or library and not implement this yourself. This demo uses the IdentityModel Nuget package to support the flow. The client UI is implemented using an ASP.NET Core Razor page web application and some javascript packages. The Microsoft.AspNetCore.Authentication.OpenIdConnect Nuget package is also required.

<ItemGroup>
  <PackageReference 
    Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" 
    Version="5.0.2" />
  <PackageReference Include="IdentityModel" Version="5.0.0" />
</ItemGroup>

The startup class or the ASP.NET Core Razor page application uses the ConfigureServices to setup the services. A session is used as well as the specific implementation services. After the application has received an authenticated identity from Azure AD, the data for this identity is stored in a cookie. The cookie stores the claims and the tokens returned from the Azure AD identity provider.

public void ConfigureServices(IServiceCollection services)
{
	// ... 

	services.AddScoped<DeviceFlowService>();
	services.AddScoped<AuthenticationSignInService>();
	services.AddHttpClient();
	services.Configure<AzureAdConfiguration>(
	   Configuration.GetSection("AzureAd"));

	services.AddSession(options =>
	{
		options.IdleTimeout = TimeSpan.FromSeconds(60);
		options.Cookie.HttpOnly = true;
	});

	services.AddAuthentication(options =>
	{
		options.DefaultScheme = 
		   CookieAuthenticationDefaults.AuthenticationScheme;
	})
	.AddCookie();

	services.AddAuthorization();
	services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();

	services.AddRazorPages();
}

The Configure method configures the middleware like any other ASP.NET Core Razor page application with authentication. The seesion middleware is also applied.

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
	app.UseStaticFiles();
	app.UseCookiePolicy();
	app.UseSession();

	app.UseStaticFiles();

	app.UseRouting();

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

	app.UseEndpoints(endpoints =>
	{
		endpoints.MapRazorPages();
	});
}

Implement the Device code flow client

Azure AD requires an instance, tenantId and a clientId to setup the configuration for the device code client. I based this on the Microsoft.Identity.Web configurations.

{
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "TenantId": "7ff95b15-dc21-4ba6-bc92-824856578fc1",
    "ClientId": "f81baf3d-f8f3-4976-8b5a-798ff57daab5"
  },
}

The DiscoveryDocumentRequest class from the IdentityModel nuget package is used to request the well-known endpoints of the Azure AD tenant. The endpoint validation is disabled because Azure AD uses different domains.

private readonly AzureAdConfiguration _azureAdConfiguration;
private readonly IHttpClientFactory _clientFactory;
private readonly DiscoveryDocumentRequest _discoveryDocumentRequest;

public DeviceFlowService(IOptions<AzureAdConfiguration> azureAdConfiguration, 
	IHttpClientFactory clientFactory)
{
	_azureAdConfiguration = azureAdConfiguration.Value;
	_clientFactory = clientFactory;
	var idpEndpoint = $"{_azureAdConfiguration.Instance}{_azureAdConfiguration.TenantId}/v2.0";
	_discoveryDocumentRequest = new DiscoveryDocumentRequest
	{
		Address = idpEndpoint,
		Policy = new DiscoveryPolicy
		{
			// turned off => Azure AD uses different domains.
			ValidateEndpoints = false
		}
	};
}

To begin the authorization process, like in the RFC 8628, a device code is requested using the RequestDeviceAuthorizationAsync method. This requests a device code and some other information to request the access token and id_token on a separate device where you can use strong authentication or whatever.

public async Task<DeviceAuthorizationResponse> GetDeviceCode()
{
	var client = _clientFactory.CreateClient();

	var disco = await HttpClientDiscoveryExtensions
		.GetDiscoveryDocumentAsync(client, _discoveryDocumentRequest);

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

	var deviceAuthorizationRequest = new DeviceAuthorizationRequest
	{
		Address = disco.DeviceAuthorizationEndpoint,
		ClientId = _azureAdConfiguration.ClientId
	};
	deviceAuthorizationRequest.Scope = "email profile openid";
	var response = await client.RequestDeviceAuthorizationAsync(deviceAuthorizationRequest);

	if (response.IsError)
	{
		throw new Exception(response.Error);
	}

	return response;
}

The PollTokenRequests method uses the device code and polls the identity provider/ secure token server until it receives a valid token or times out. While this is running, the user can open the link provided and enter the code plus the required authentication for the identity. The user can use strong authentication with MFA, FIDO2 and so on. After a successful authentication, the tokens are returned to the application.

public async Task<TokenResponse> PollTokenRequests(string deviceCode, int interval)
{
	var client = _clientFactory.CreateClient();

	var disco = await HttpClientDiscoveryExtensions
	   .GetDiscoveryDocumentAsync(client, _discoveryDocumentRequest);

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

	while (true)
	{
		if(!string.IsNullOrWhiteSpace(deviceCode))
		{
			var response = await client
			  .RequestDeviceTokenAsync(new DeviceTokenRequest
			{
				Address = disco.TokenEndpoint,
				ClientId = _azureAdConfiguration.ClientId,
				DeviceCode = deviceCode
			});

			if (response.IsError)
			{
				if (response.Error == "authorization_pending" 
				  || response.Error == "slow_down")
				{
					Console.WriteLine($"{response.Error}...waiting.");
					await Task.Delay(interval * 1000);
				}
				else
				{
					throw new Exception(response.Error);
				}
			}
			else
			{
				return response;
			}
		}
		else
		{
			await Task.Delay(interval * 1000);
		}
	}
}

The LoginModel Razor page uses the scoped services and provides a UI to initialize the device code flow process.

using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace DeviceFlowWeb.Pages
{
    public class LoginModel : PageModel
    {
        private readonly DeviceFlowService _deviceFlowService;
        private readonly AuthenticationSignInService _authenticationSignInService;

        public string AuthenticatorUri { get; set; }

        public string UserCode { get; set; }

        public LoginModel(DeviceFlowService deviceFlowService, AuthenticationSignInService authenticationSignInService)
        {
            _deviceFlowService = deviceFlowService;
            _authenticationSignInService = authenticationSignInService;
        }

        public async Task OnGetAsync()
        {
            HttpContext.Session.SetString("DeviceCode", string.Empty);

            await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);

            var deviceAuthorizationResponse = await _deviceFlowService.GetDeviceCode();
            AuthenticatorUri = deviceAuthorizationResponse.VerificationUri;
            UserCode = deviceAuthorizationResponse.UserCode;

            if (string.IsNullOrEmpty(HttpContext.Session.GetString("DeviceCode")))
            {
                HttpContext.Session.SetString("DeviceCode", deviceAuthorizationResponse.DeviceCode);
                HttpContext.Session.SetInt32("Interval", deviceAuthorizationResponse.Interval);
            }
        }

        public async Task<IActionResult> OnPostAsync()
        {
            var deviceCode = HttpContext.Session.GetString("DeviceCode");
            var interval = HttpContext.Session.GetInt32("Interval");

            if(interval.GetValueOrDefault() <= 0)
            {
                interval = 5;
            }

            var tokenresponse = await _deviceFlowService.PollTokenRequests(deviceCode, interval.Value);

            if (tokenresponse.IsError)
            {
                ModelState.AddModelError(string.Empty, "Invalid login attempt.");
                return Page();
            }

            await _authenticationSignInService.SignIn(HttpContext,
                tokenresponse.AccessToken, 
                tokenresponse.IdentityToken,
                tokenresponse.ExpiresIn);

            return Redirect("/Index");
        }

    }
}

The Razor page template displays the data and also a QR code of the link to enter the code. This would be useful as the user would for example use a mobile phone to complete the authentication process.

@page
@model DeviceFlowWeb.Pages.LoginModel
@{
    ViewData["Title"] = "Login";
    Layout = "~/Pages/Shared/_Layout.cshtml";
}


Login: <p>@Model.AuthenticatorUri</p>

<br /><br />

User Code: <p>@Model.UserCode</p>
<br />
<br />

<div id="qrCode"></div>
<div id="qrCodeData" data-url="@Html.Raw(Model.AuthenticatorUri)"></div>

<br />
<br />

<form data-ajax="true"  method="post" data-ajax-method="POST">
    <button class="btn btn-secondary" 
            name="begin_token_check" 
            id="begin_token_check" type="submit" style="visibility:hidden">Get device code</button>
</form>

@section scripts {
<script src="~/js/qrcode.min.js"></script>
<script type="text/javascript">
        new QRCode(document.getElementById("qrCode"),
            {
                text: "@Html.Raw(Model.AuthenticatorUri)",
                width: 150,
                height: 150
            });

    $(document).ready(() => {
        document.getElementById('begin_token_check').click();
    });

</script>
}

After a successful authentication, the tokens are added to a cookie. The claims from the id_token are added to the claims principal and the access token is also added. The identity is then signed-in and the token is sent with each request from the client browser to the server until it expires.

public async Task SignIn(HttpContext httpContext, 
   string accessToken, string idToken, int expiresIn)
{
	var claims = GetClaims(idToken);

	var claimsIdentity = new ClaimsIdentity(
		claims,
		CookieAuthenticationDefaults.AuthenticationScheme,
		"name",
		"user");

	var authProperties = new AuthenticationProperties();
	authProperties.ExpiresUtc = DateTime.UtcNow.AddSeconds(expiresIn);

	// save the tokens in the cookie
	authProperties.StoreTokens(new List<AuthenticationToken>
	{
		new AuthenticationToken
		{
			Name = "access_token",
			Value = accessToken
		},
		new AuthenticationToken
		{
			Name = "id_token",
			Value = idToken
		}
	});

	await httpContext.SignInAsync(
		CookieAuthenticationDefaults.AuthenticationScheme,
		new ClaimsPrincipal(claimsIdentity),
		authProperties);
}

private IEnumerable<Claim> GetClaims(string token)
{
	var validJwt = new JwtSecurityToken(token);
	return validJwt.Claims;
}

Now the application can be started and everything should work. You would need to add an App registration to your tenant and change the configuration to match to run this yourself.

Implementing the device code is really simple and made easy by using the IdentityModel nuget package. Azure Microsoft also provides its own client library with support for the device code flow. Maybe in a follow up post, I will implement a client using this and add an API to acces data.

Links

https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-device-code

https://tools.ietf.org/wg/oauth/draft-ietf-oauth-device-flow/

https://damienbod.com/2019/02/20/asp-net-core-oauth-device-flow-client-with-identityserver4/

https://github.com/Azure-Samples/active-directory-dotnetcore-devicecodeflow-v2

https://docs.microsoft.com/en-us/aspnet/core/security/authentication/cookie

https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/wiki/Device-Code-Flow

Securing Blazor Web assembly using cookies

$
0
0

The article shows how a Blazor web assembly UI hosted in an ASP.NET Core application can be secured using cookies. Azure AD is used as the identity provider and the Microsoft.Identity.Web Nuget package is used to secure the trusted server rendered application. The API calls are protected using the secure cookie and anti-forgery tokens to protected against CSRF. This architecture is also known as the Backends for Frontends (BFF) Pattern.

Code: Blazor Cookie security

Why Cookies

By using cookies, it gives us the possiblity to increase the security of the whole application, UI + API. Blazor web assembly is treated as a UI in the server rendered application. By using cookies, no access tokens, refresh tokens or id tokens are saved or managed in the browser. All security is implemented in the trusted backend. By implementing the security in the trusted backend, the application can be authenticated by the identity provider and all access tokens are removed from the browser, web storage. With the correct security definitions on the cookies, the security risks can be reduced, and the client application can be authenticated. It would be possible to use sender constrained tokens, or Mutual TLS for increased security, if this was required. Anti-forgery tokens are required to secure the API requests because we use cookies.

The UI and the backend are one application which are coupled together. This is different to the standard Blazor template which uses access tokens. The WASM and the API are secured as two separate applications. Here only a single server rendered application is secured. The WASM client can only use APIs hosted on the same domain.

Credits

Some of the code in this repo was built using original source code from Bernd Hirschmann.

Thank you for the git repository.

Creating the Blazor application

The Blazor application was created using a web assembly template hosted in an ASP.NET Core application. You need to check the checkbox in Visual Studio for this. No authentication was added. This creates three projects. We will add the security first, then the services to use the Identity of the authenticated user in the WASM client, and then add the bits required for CSRF protection.

Securing the application using Azure AD

The application is secured using the Azure AD identity provider. This is implemented using the Microsoft.Identity.Web web application client, not API client. This is just a wrapper for the Open ID connect code flow authentication and if successful authenticated, the auth data is stored in a cookie. Two Azure App Registrations are used to implement this, one for the API and one for the Web authentication. A client secret is required to access the API. A certificate could also be used instead. See the Microsoft.Identity.Web docs for more info.

The app.settings contains the configuration for both the API and the web client. The ScopeForAccessToken contains all the scopes required by the application so that after the user authenticates, the user can give consent up front for all required APIs. The rest is standard.

"AzureAd": {
	"Instance": "https://login.microsoftonline.com/",
	"Domain": "damienbodhotmail.onmicrosoft.com",
	"TenantId": "7ff95b15-dc21-4ba6-bc92-824856578fc1",
	"ClientId": "46d2f651-813a-4b5c-8a43-63abcb4f692c",
	"CallbackPath": "/signin-oidc",
	"SignedOutCallbackPath ": "/signout-callback-oidc"
	// "ClientSecret": "add secret to the user secrets"
},
	"UserApiOne": {
	"ScopeForAccessToken": "api://b2a09168-54e2-4bc4-af92-a710a64ef1fa/access_as_user User.ReadBasic.All user.read",
	"ApiBaseAddress": "https://localhost:44395"
},

The following nuget packages were added to the server blazor host application.

  • Microsoft.AspNetCore.Components.WebAssembly.Server
  • Microsoft.AspNetCore.Authentication.JwtBearer
  • Microsoft.AspNetCore.Authentication.OpenIdConnect
  • Microsoft.Identity.Web
  • Microsoft.Identity.Web.UI
  • Microsoft.Identity.Web.MicrosoftGraphBeta
  • IdentityModel
  • IdentityModel.AspNetCore

The startup ConfigureServices method is used to add the Azure AD authentication clients. The AddMicrosoftIdentityWebAppAuthentication method is used to add the web client which uses the services added in the AddMicrosoftIdentityUI method. Graph API is added as a downstream API demo.

public void ConfigureServices(IServiceCollection services)
{
    // + ...
	
	services.AddHttpClient();
	services.AddOptions();

	string[] initialScopes = Configuration.GetValue<string>(
		"UserApiOne:ScopeForAccessToken")?.Split(' ');

	services.AddMicrosoftIdentityWebAppAuthentication(Configuration)
		.EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
		 .AddMicrosoftGraph("https://graph.microsoft.com/beta",
			"User.ReadBasic.All user.read")
		.AddInMemoryTokenCaches();

	services.AddRazorPages().AddMvcOptions(options =>
	{
		var policy = new AuthorizationPolicyBuilder()
			.RequireAuthenticatedUser()
			.Build();
		options.Filters.Add(new AuthorizeFilter(policy));
	}).AddMicrosoftIdentityUI();
}

The Configure method is used to add the middleware in the correct order. The Blazor application is setup like the template except for the fallback which maps to the razor page _Host instead of the index. This was added to support anti forgery tokens which I’ll explain later in this blog.

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
	if (env.IsDevelopment())
	{
		app.UseDeveloperExceptionPage();
		app.UseWebAssemblyDebugging();
	}
	else
	{
		app.UseExceptionHandler("/Error");
		app.UseHsts();
	}

	app.UseHttpsRedirection();
	app.UseBlazorFrameworkFiles();
	app.UseStaticFiles();

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

	app.UseEndpoints(endpoints =>
	{
		endpoints.MapRazorPages();
		endpoints.MapControllers();
		endpoints.MapFallbackToPage("/_Host");
	});
}

Now the APIs can be protected using the Authorize attribute with the cookie scheme. The AuthorizeForScopes which come from the Microsoft.Identity.Web Nuget package can be used to validate the scope and handle MSAL consent exceptions.

[ValidateAntiForgeryToken]
[Authorize(AuthenticationSchemes = CookieAuthenticationDefaults.AuthenticationScheme)]
[AuthorizeForScopes(Scopes = new string[] { "api://b2a09168-54e2-4bc4-af92-a710a64ef1fa/access_as_user" })]
[ApiController]
[Route("api/[controller]")]
public class DirectApiController : ControllerBase
{
	[HttpGet]
	public IEnumerable<string> Get()
	{
		return new List<string> { "some data", "more data", "loads of data" };
	}
}

Using the claims, identity in the web assembly client application

The next part of the code was implemented using the source code created by Bernd Hirschmann. Now that the server authentication is implemented and the identity exists for the user and the application, the claims from this identity and the state of the actual user needs to be accessed and used in the client web assembly part of the application. APIs need to be created for this purpose. The account controller is used to initialize the sign in flow and a HTTP Post can be used to sign out.

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace BlazorAzureADWithApis.Server.Controllers
{
    // orig src https://github.com/berhir/BlazorWebAssemblyCookieAuth
    [Route("api/[controller]")]
    public class AccountController : ControllerBase
    {
        [HttpGet("Login")]
        public ActionResult Login(string returnUrl)
        {
            return Challenge(new AuthenticationProperties
            {
                RedirectUri = !string.IsNullOrEmpty(returnUrl) ? returnUrl : "/"
            });
        }

        // [ValidateAntiForgeryToken] // not needed explicitly due the the auto global definition.
        [Authorize]
        [HttpPost("Logout")]
        public IActionResult Logout()
        {
            return SignOut(
                new AuthenticationProperties { RedirectUri = "/" },
                CookieAuthenticationDefaults.AuthenticationScheme,
                OpenIdConnectDefaults.AuthenticationScheme);
        }
    }
}

The UserController is used to for the WASM to get access about the current identity and the claims of this identity.

using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using BlazorAzureADWithApis.Shared.Authorization;
using IdentityModel;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace BlazorAzureADWithApis.Server.Controllers
{
    // orig src https://github.com/berhir/BlazorWebAssemblyCookieAuth
    [Route("api/[controller]")]
    [ApiController]
    public class UserController : ControllerBase
    {
        [HttpGet]
        [AllowAnonymous]
        public IActionResult GetCurrentUser()
        {
            return Ok(User.Identity.IsAuthenticated ? CreateUserInfo(User) : UserInfo.Anonymous);
        }

        private UserInfo CreateUserInfo(ClaimsPrincipal claimsPrincipal)
        {
            if (!claimsPrincipal.Identity.IsAuthenticated)
            {
                return UserInfo.Anonymous;
            }

            var userInfo = new UserInfo
            {
                IsAuthenticated = true
            };

            if (claimsPrincipal.Identity is ClaimsIdentity claimsIdentity)
            {
                userInfo.NameClaimType = claimsIdentity.NameClaimType;
                userInfo.RoleClaimType = claimsIdentity.RoleClaimType;
            }
            else
            {
                userInfo.NameClaimType = JwtClaimTypes.Name;
                userInfo.RoleClaimType = JwtClaimTypes.Role;
            }

            if (claimsPrincipal.Claims.Any())
            {
                var claims = new List<ClaimValue>();
                var nameClaims = claimsPrincipal.FindAll(userInfo.NameClaimType);
                foreach (var claim in nameClaims)
                {
                    claims.Add(new ClaimValue(userInfo.NameClaimType, claim.Value));
                }

                // Uncomment this code if you want to send additional claims to the client.
                //foreach (var claim in claimsPrincipal.Claims.Except(nameClaims))
                //{
                //    claims.Add(new ClaimValue(claim.Type, claim.Value));
                //}

                userInfo.Claims = claims;
            }

            return userInfo;
        }
    }
}

In the client project, the services are added in the program file. The HttpClients are added as well as the AuthenticationStateProvider which can be used in the client UI.

using BlazorAzureADWithApis.Client.Services;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;

namespace BlazorAzureADWithApis.Client
{
    public class Program
    {
        public static async Task Main(string[] args)
        {
            var builder = WebAssemblyHostBuilder.CreateDefault(args);
            builder.Services.AddOptions();
            builder.Services.AddAuthorizationCore();
            builder.Services.TryAddSingleton<AuthenticationStateProvider, HostAuthenticationStateProvider>();
            builder.Services.TryAddSingleton(sp => (HostAuthenticationStateProvider)sp.GetRequiredService<AuthenticationStateProvider>());
            builder.Services.AddTransient<AuthorizedHandler>();

            builder.Services.AddHttpClient("default", client =>
            {
                client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress);
                client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

            });

            builder.Services.AddHttpClient("authorizedClient", client =>
            {
                client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
            }).AddHttpMessageHandler<AuthorizedHandler>();

            builder.Services.AddTransient(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("default"));

            await builder.Build().RunAsync();
        }
    }
}

The HostAuthenticationStateProvider implements the AuthenticationStateProvider and is used to call the user controller APIs and return the state to the UI.

using BlazorAzureADWithApis.Shared.Authorization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging;
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Threading.Tasks;

namespace BlazorAzureADWithApis.Client.Services
{
    // orig src https://github.com/berhir/BlazorWebAssemblyCookieAuth
    public class HostAuthenticationStateProvider : AuthenticationStateProvider
    {
        private static readonly TimeSpan _userCacheRefreshInterval = TimeSpan.FromSeconds(60);

        private const string LogInPath = "api/Account/Login";
        private const string LogOutPath = "api/Account/Logout";

        private readonly NavigationManager _navigation;
        private readonly HttpClient _client;
        private readonly ILogger<HostAuthenticationStateProvider> _logger;

        private DateTimeOffset _userLastCheck = DateTimeOffset.FromUnixTimeSeconds(0);
        private ClaimsPrincipal _cachedUser = new ClaimsPrincipal(new ClaimsIdentity());

        public HostAuthenticationStateProvider(NavigationManager navigation, HttpClient client, ILogger<HostAuthenticationStateProvider> logger)
        {
            _navigation = navigation;
            _client = client;
            _logger = logger;
        }

        public override async Task<AuthenticationState> GetAuthenticationStateAsync()
        {
            return new AuthenticationState(await GetUser(useCache: true));
        }

        public void SignIn(string customReturnUrl = null)
        {
            var returnUrl = customReturnUrl != null ? _navigation.ToAbsoluteUri(customReturnUrl).ToString() : null;
            var encodedReturnUrl = Uri.EscapeDataString(returnUrl ?? _navigation.Uri);
            var logInUrl = _navigation.ToAbsoluteUri($"{LogInPath}?returnUrl={encodedReturnUrl}");
            _navigation.NavigateTo(logInUrl.ToString(), true);
        }

        private async ValueTask<ClaimsPrincipal> GetUser(bool useCache = false)
        {
            var now = DateTimeOffset.Now;
            if (useCache && now < _userLastCheck + _userCacheRefreshInterval)
            {
                _logger.LogDebug("Taking user from cache");
                return _cachedUser;
            }

            _logger.LogDebug("Fetching user");
            _cachedUser = await FetchUser();
            _userLastCheck = now;

            return _cachedUser;
        }

        private async Task<ClaimsPrincipal> FetchUser()
        {
            UserInfo user = null;

            try
            {
                _logger.LogInformation(_client.BaseAddress.ToString());
                user = await _client.GetFromJsonAsync<UserInfo>("api/User");
            }
            catch (Exception exc)
            {
                _logger.LogWarning(exc, "Fetching user failed.");
            }

            if (user == null || !user.IsAuthenticated)
            {
                return new ClaimsPrincipal(new ClaimsIdentity());
            }

            var identity = new ClaimsIdentity(
                nameof(HostAuthenticationStateProvider),
                user.NameClaimType,
                user.RoleClaimType);

            if (user.Claims != null)
            {
                foreach (var claim in user.Claims)
                {
                    identity.AddClaim(new Claim(claim.Type, claim.Value));
                }
            }

            return new ClaimsPrincipal(identity);
        }
    }
}

The AuthorizedHandler implements the DelegatingHandler which can be used to add headers or handle HTTP request logic when the user is authenticated.

using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

namespace BlazorAzureADWithApis.Client.Services
{
    // orig src https://github.com/berhir/BlazorWebAssemblyCookieAuth
    public class AuthorizedHandler : DelegatingHandler
    {
        private readonly HostAuthenticationStateProvider _authenticationStateProvider;

        public AuthorizedHandler(HostAuthenticationStateProvider authenticationStateProvider)
        {
            _authenticationStateProvider = authenticationStateProvider;
        }

        protected override async Task<HttpResponseMessage> SendAsync(
            HttpRequestMessage request,
            CancellationToken cancellationToken)
        {
            var authState = await _authenticationStateProvider.GetAuthenticationStateAsync();
            HttpResponseMessage responseMessage;
            if (!authState.User.Identity.IsAuthenticated)
            {
                // if user is not authenticated, immediately set response status to 401 Unauthorized
                responseMessage = new HttpResponseMessage(HttpStatusCode.Unauthorized);
            }
            else
            {
                responseMessage = await base.SendAsync(request, cancellationToken);
            }

            if (responseMessage.StatusCode == HttpStatusCode.Unauthorized)
            {
                // if server returned 401 Unauthorized, redirect to login page
                _authenticationStateProvider.SignIn();
            }

            return responseMessage;
        }
    }
}

Now the AuthorizeView and the Authorized components can be used to hide or display the UI elements depending on the authentication state of the identity.

@inherits LayoutComponentBase

<div class="page">
    <div class="sidebar">
        <NavMenu />
    </div>

    <div class="main">
        <div class="top-row px-4 auth">
            <AuthorizeView>
                <Authorized>
                    <strong>Hello, @context.User.Identity.Name!</strong>
                    <form method="post" action="api//Account/Logout">
                        <AntiForgeryTokenInput/>
                        <button class="btn btn-link" type="submit">Sign out</button>
                    </form>
                </Authorized>
                <NotAuthorized>
                    <a href="Account/Login">Log in</a>
                </NotAuthorized>
            </AuthorizeView>

        </div>

        <div class="content px-4">
            @Body
        </div>
    </div>
</div>

For more information on thes see the Microsoft docs or this blog.

Cross-site request forgery CSRF protection

Cross-site request forgery (also known as XSRF or CSRF) is a possible security problem when using cookies. We can protect against this using anti-forgery tokens and will add this to the Blazor application. To support this, we can use a Razor page _Host.cshtml file instead of a static html file. This host page is added to the server project and uses the default div with the id app just like the index.html file from the dotnet template. The index.html can be deleted form the client project. The render-mode is per default WebAssembly. If you copied a _Host file from a server Blazor template, you would have to change this or remove it.

The Anti-forgery token is added at the bottom of the file in the body. A antiForgeryToken.js is also added to the razor Page _Host file. Also make sure the headers match the headers from the index.html which you deleted.

@page "/"
@namespace BlazorAzureADWithApis.Pages
@using BlazorAzureADWithApis.Client
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
    Layout = null;
}

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
    <title>Blazor AAD Cookie</title>
    <base href="~/" />
    <link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
    <link href="css/app.css" rel="stylesheet" />
    <link href="BlazorAzureADWithApis.Client.styles.css" rel="stylesheet" />
    <link href="manifest.json" rel="manifest" />
    <link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
</head>
<body>

    <div id="app">
        <!-- Spinner -->
        <div class="spinner d-flex align-items-center justify-content-center" style="position:absolute; width: 100%; height: 100%; background: #d3d3d39c; left: 0; top: 0; border-radius: 10px;">
            <div class="spinner-border text-success" role="status">
                <span class="sr-only">Loading...</span>
            </div>
        </div>
    </div>

    <div id="blazor-error-ui">
        <environment include="Staging,Production">
            An error has occurred. This application may no longer respond until reloaded.
        </environment>
        <environment include="Development">
            An unhandled exception has occurred. See browser dev tools for details.
        </environment>
        <a href="" class="reload">Reload</a>
        <a class="dismiss">🗙</a>
    </div>

    <script src="_framework/blazor.webassembly.js"></script>
    <script src="antiForgeryToken.js"></script>
    @Html.AntiForgeryToken()
</body>
</html>

The MapFallbackToPage needs to be updated to use the _Host file instead of the static html.

app.UseEndpoints(endpoints =>
{
	endpoints.MapRazorPages();
	endpoints.MapControllers();
	endpoints.MapFallbackToPage("/_Host");
});

The AddAntiforgery is used to add the service for the CSRF protection by using a header named X-XSRF-TOKEN. The AutoValidateAntiforgeryTokenAttribute is added so that all POST, PUT, DELETE Http requests require an anti-forgery token.

public void ConfigureServices(IServiceCollection services)
{
    // + ...
	
	services.AddAntiforgery(options =>
	{
		options.HeaderName = "X-XSRF-TOKEN";
	});

	services.AddControllersWithViews(options =>
		options.Filters.Add(
			new AutoValidateAntiforgeryTokenAttribute()));

}

The antiForgeryToken.js Javascript file uses the hidden input created by the _Host Razor Page file and returns this in a function.


function getAntiForgeryToken() {
    var elements = document.getElementsByName('__RequestVerificationToken');
    if (elements.length > 0) {
        return elements[0].value
    }

    console.warn('no anti forgery token found!');
    return null;
}

The Javascript function can be used in any Blazor component now by using the JSRuntime. The anti-forgery token can be added to the X-XSRF-TOKEN HTTP request header which is configured in the server Startup class.

@page "/directapi"
@inject HttpClient Http
@inject IJSRuntime JSRuntime

<h1>Data from Direct API</h1>

@if (apiData == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>Data</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var data in apiData)
            {
                <tr>
                    <td>@data</td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    private string[] apiData;

    protected override async Task OnInitializedAsync()
    {
        var token = await JSRuntime.InvokeAsync<string>("getAntiForgeryToken");

        Http.DefaultRequestHeaders.Add("X-XSRF-TOKEN", token);

        apiData = await Http.GetFromJsonAsync<string[]>("api/DirectApi");
    }

}

If you are using forms directly in the Blazor template, then a custom component which creates a hidden input can be used to add the anti forgery token to the HTTP POST, PUT, DELETE requests. Underneath is a new component called AntiForgeryTokenInput.

@inject IJSRuntime JSRuntime

<input type="hidden" id="__RequestVerificationToken"
       name="__RequestVerificationToken" value="@GetToken()">

@code {

    private string token = "";

    protected override async Task OnInitializedAsync()
    {
        token = await JSRuntime.InvokeAsync<string>("getAntiForgeryToken");
    }

    public string GetToken()
    {
        return token;
    }

}

The AntiForgeryTokenInput can be used directly in the HTML code.

<form method="post" action="api/Account/Logout">
	<AntiForgeryTokenInput/>
	<button class="btn btn-link" type="submit">Sign out</button>
</form>

In the server application, the ValidateAntiForgeryToken attribute can be used the force using anti forgery token protection explicitly.

[ValidateAntiForgeryToken] 
[Authorize]
[HttpPost("Logout")]
public IActionResult Logout()
{
	return SignOut(
		new AuthenticationProperties { RedirectUri = "/" },
		CookieAuthenticationDefaults.AuthenticationScheme,
		OpenIdConnectDefaults.AuthenticationScheme);
}

Using cookies with Blazor WASM and ASP.NET Core hosted applications can be used to support the high security flow requirements which are required for certain application deployments. This makes it possible to add extra layers of security just by having a trusted application implement the security parts. The Blazor client application can only use the API deployed on the host in the same domain. Any Open ID Connect provider can be supported in this way, just like a Razor Page application. This makes it easier to support logout requirements by using a OIDC backchannel logout and so on. MTLS and sender constrained tokens can also be supported with this setup. SignalR no longer needs to add the access tokens to the URL of the web sockets as cookies can be used on the same domain.

Would love feedback on further ways of improving this.

Links:

https://github.com/berhir/BlazorWebAssemblyCookieAuth

Secure a Blazor WebAssembly application with cookie authentication

https://docs.microsoft.com/en-us/aspnet/core/blazor/components/prerendering-and-integration?view=aspnetcore-5.0&pivots=webassembly#configuration

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

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

Securing Blazor Server App using IdentityServer4

https://github.com/saber-wang/BlazorAppFormTset

https://jonhilton.net/blazor-wasm-prerendering-missing-http-client/

https://andrewlock.net/enabling-prerendering-for-blazor-webassembly-apps/

https://docs.microsoft.com/en-us/aspnet/core/blazor/security/webassembly/additional-scenarios

Implement Azure AD Continuous Access Evaluation in an ASP.NET Core Razor Page app using a Web API

$
0
0

This article shows how Azure AD continuous access evaluation (CAE) can be used in an ASP.NET Core UI application to force MFA when using an administrator API from a separate ASP.NET Core application. Both applications are secured using Microsoft.Identity.Web. An ASP.NET Core Razor Page application is used to implement the UI application. The API is implemented with swagger open API and ASP.NET Core. An Azure AD conditional access authentication context is used to implement the MFA requirement. An Azure AD CAE policy is setup which requires the defines MFA and uses the context.

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

Continuous access evaluation (CAE) requires an Azure AD P2 license to use the authentication context. If your applications are deployed to any other tenant type, this is will not work.

Requirements

  • Azure AD tenant with P2 license
  • Microsoft Graph

Create a Conditional access Authentication Context

A Continuous access evaluation (CAE) authentication context was created using Microsoft Graph and can be viewed in the portal. In this demo, like the Microsoft sample application, three authentication contexts are created using Microsoft Graph. The Policy.Read.ConditionalAccess Policy.ReadWrite.ConditionalAccess permissions are required to change the CAE authentication contexts.

This is only needed to create the CAE authentication contexts. Once created, this can be used in the target applications.

public async Task CreateAuthContextViaGraph(string acrKey, string acrValue)
{
	await _graphAuthContextAdmin.CreateAuthContextClassReferenceAsync(
		acrKey, 
		acrValue, 
		$"A new Authentication Context Class Reference created at {DateTime.UtcNow}", 
		true);
}

public async Task<AuthenticationContextClassReference?> 
	CreateAuthContextClassReferenceAsync(
		string id, 
		string displayName, 
		string description, 
		bool IsAvailable)
{
	try
	{
		var acr = await _graphServiceClient
			.Identity
			.ConditionalAccess
			.AuthenticationContextClassReferences
			.Request()
			.AddAsync(new AuthenticationContextClassReference
			{
				Id = id,
				DisplayName = displayName,
				Description = description,
				IsAvailable = IsAvailable,
				ODataType = null
			});

		return acr;
	}
	catch (ServiceException e)
	{
		_logger.LogWarning(
		"We could not add a new ACR: {exception}",  e.Error.Message);

		return null;
	}
}

The created conditional access authentication context can be viewed in the portal in the Security blade of the Azure AD tenant.

If you open the context, you can see the id used. This is used in the applications to check the MFA requirement.

Create a CAE policy to use the context

Now that a authentication context exists, a CAE policy can be created to use this. I created a policy to require MFA.

Implement the API and use the CAE context

The API application needs to validate if the access token contains the acrs claim with the c1 value. If CAE is activated and the claim is included in the token, then any policies which use this CAE authentication context must be fulfilled or no events have been received which inform the client that this access token is invalid. A lot of things need to be implemented correctly for this to work. If configured correctly, a MFA step up authentication is required to use the API. The API returns an unauthorized response as specified in the OpenID Connect signals and events specification, if the claim is missing from the access token. This is handled by the calling UI application.

/// <summary>
/// Claims challenges, claims requests, and client capabilities
/// 
/// https://docs.microsoft.com/en-us/azure/active-directory/develop/claims-challenge
/// 
/// Applications that use enhanced security features like Continuous Access Evaluation (CAE) 
/// and Conditional Access authentication context must be prepared to handle claims challenges.
/// </summary>
public class CAECliamsChallengeService
{
    private readonly IConfiguration _configuration;

    public CAECliamsChallengeService(IConfiguration configuration)
    {
        _configuration = configuration;
    }
    /// <summary>
    /// Retrieves the acrsValue from database for the request method.
    /// Checks if the access token has acrs claim with acrsValue.
    /// If does not exists then adds WWW-Authenticate and throws UnauthorizedAccessException exception.
    /// </summary>
    public void CheckForRequiredAuthContext(string authContextId, HttpContext context)
    {
        if (!string.IsNullOrEmpty(authContextId))
        {
            string authenticationContextClassReferencesClaim = "acrs";

            if (context == null || context.User == null || context.User.Claims == null || !context.User.Claims.Any())
            {
                throw new ArgumentNullException(nameof(context), "No Usercontext is available to pick claims from");
            }

            var acrsClaim = context.User.FindAll(authenticationContextClassReferencesClaim).FirstOrDefault(x => x.Value == authContextId);

            if (acrsClaim?.Value != authContextId)
            {
                if (IsClientCapableofClaimsChallenge(context))
                {
                    string clientId = _configuration.GetSection("AzureAd").GetSection("ClientId").Value;
                    var base64str = Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"access_token\":{\"acrs\":{\"essential\":true,\"value\":\"" + authContextId + "\"}}}"));

                    context.Response.Headers.Append("WWW-Authenticate", $"Bearer realm=\"\", authorization_uri=\"https://login.microsoftonline.com/common/oauth2/authorize\", client_id=\"" + clientId + "\", error=\"insufficient_claims\", claims=\"" + base64str + "\", cc_type=\"authcontext\"");
                    context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
                    string message = string.Format(CultureInfo.InvariantCulture, "The presented access tokens had insufficient claims. Please request for claims requested in the WWW-Authentication header and try again.");
                    context.Response.WriteAsync(message);
                    context.Response.CompleteAsync();
                    throw new UnauthorizedAccessException(message);
                }
                else
                {
                    throw new UnauthorizedAccessException("The caller does not meet the authentication  bar to carry our this operation. The service cannot allow this operation");
                }
            }
        }
    }

    /// <summary>
    /// Evaluates for the presence of the client capabilities claim (xms_cc) and accordingly returns a response if present.
    /// </summary>
    public bool IsClientCapableofClaimsChallenge(HttpContext context)
    {
        string clientCapabilitiesClaim = "xms_cc";

        if (context == null || context.User == null || context.User.Claims == null || !context.User.Claims.Any())
        {
            throw new ArgumentNullException(nameof(context), "No Usercontext is available to pick claims from");
        }

        var ccClaim = context.User.FindAll(clientCapabilitiesClaim).FirstOrDefault(x => x.Type == "xms_cc");

        if (ccClaim != null && ccClaim.Value == "cp1")
        {
            return true;
        }

        return false;
    }
}

The API uses the CAE scoped service to validate the CAE authentication context and either the data is returned or an unauthorized exception is returned. The Authorize attribute is also used to validate the JWT bearer token and validate that the authentication policy is supported. You could probably implement middleware to check the CAE authentication context as well.

[Authorize(Policy = "ValidateAccessTokenPolicy", AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[ApiController]
[Route("[controller]")]
public class ApiForUserDataController : ControllerBase
{
    private readonly CAECliamsChallengeService _caeCliamsChallengeService;

    public ApiForUserDataController(CAECliamsChallengeService caeCliamsChallengeService)
    {
        _caeCliamsChallengeService = caeCliamsChallengeService;
    }

    [HttpGet]
    public IEnumerable<string> Get()
    {
        // returns unauthorized exception with WWW-Authenticate header if CAE claim missing in access token
        // handled in the caller client exception with challenge returned if not ok
        _caeCliamsChallengeService.CheckForRequiredAuthContext(AuthContextId.C1, HttpContext);
        return new List<string> { "admin API CAE protected data 1", "admin API CAE protected  data 2" };
    }
}

The program file adds the services and secure the API using Microsoft.Identity.Web. A policy is created to used on the controllers.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<CAECliamsChallengeService>();

builder.Services.AddDistributedMemoryCache();
builder.Services.AddMicrosoftIdentityWebApiAuthentication(builder.Configuration)
    .EnableTokenAcquisitionToCallDownstreamApi()
    .AddMicrosoftGraph(builder.Configuration.GetSection("GraphBeta"))
    .AddDistributedTokenCaches();

JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
//IdentityModelEventSource.ShowPII = true;

builder.Services.AddControllers(options =>
{
    var policy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build();
    options.Filters.Add(new AuthorizeFilter(policy));
});

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("ValidateAccessTokenPolicy", validateAccessTokenPolicy =>
    {
        // Validate id of application for which the token was created
        // In this case the UI application 
        validateAccessTokenPolicy.RequireClaim("azp", builder.Configuration["AzpValidClientId"]);

        // only allow tokens which used "Private key JWT Client authentication"
        // // https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens
        // Indicates how the client was authenticated. For a public client, the value is "0". 
        // If client ID and client secret are used, the value is "1". 
        // If a client certificate was used for authentication, the value is "2".
        validateAccessTokenPolicy.RequireClaim("azpacr", "1");
    });
});

The program file is used to setup the API ASP.NET Core API project like any Azure AD Microsoft.Identity.Web client.

  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "[Enter the domain of your tenant, e.g. contoso.onmicrosoft.com]",
    "TenantId": "[Enter 'common', or 'organizations' or the Tenant Id (Obtained from the Azure portal. Select 'Endpoints' from the 'App registrations' blade and use the GUID in any of the URLs), e.g. da41245a5-11b3-996c-00a8-4d99re19f292]",
    "ClientId": "[Enter the Client Id (Application ID obtained from the Azure portal), e.g. ba74781c2-53c2-442a-97c2-3d60re42f403]",
    "ClientSecret": "[Copy the client secret added to the app from the Azure portal]",
    "ClientCertificates": [
    ],
    // the following is required to handle Continuous Access Evaluation challenges
    "ClientCapabilities": [ "cp1" ],
    "CallbackPath": "/signin-oidc"
  },
  "AzpValidClientId": "7c839e15-096b-4abb-a869-df9e6b34027c",
  "GraphBeta": {
    "BaseUrl": "https://graph.microsoft.com/beta",
    "Scopes": "Policy.Read.ConditionalAccess Policy.ReadWrite.ConditionalAccess"
  },

Now that the unauthorized exception is returned to the calling UI interactive client, this needs to be handled.

Implement the ASP.NET Core Razor Page with step up MFA check

The UI project implements a Web APP project. The Admin API scope is requested to access the admin API.

builder.Services.AddDistributedMemoryCache();
builder.Services.AddMicrosoftIdentityWebAppAuthentication(builder.Configuration, 
       "AzureAd", 
       subscribeToOpenIdConnectMiddlewareDiagnosticsEvents: true)
    .EnableTokenAcquisitionToCallDownstreamApi(new[] { 
       builder.Configuration.GetSection("AdminApi")["Scope"] })
    .AddMicrosoftGraph(builder.Configuration.GetSection("GraphBeta"))
    .AddDistributedTokenCaches();

The app uses a scoped service to request data from the administrator API. Using the ITokenAcquisition interface, an access token is request for the API. If an unauthorized response is returned, then a WebApiMsalUiRequiredException exception is thrown with the response headers.

public async Task<IEnumerable<string>?> GetApiDataAsync()
{
	var client = _clientFactory.CreateClient();

	var scopes = new List<string> { _adminApiScope };
	var accessToken = await _tokenAcquisition
		.GetAccessTokenForUserAsync(scopes);

	client.BaseAddress = new Uri(_adminApiBaseUrl);
	client.DefaultRequestHeaders.Authorization = 
		new AuthenticationHeaderValue("Bearer", accessToken);
	client.DefaultRequestHeaders.Accept.Add(
		new MediaTypeWithQualityHeaderValue("application/json"));

	var response = await client.GetAsync("ApiForUserData");
	if (response.IsSuccessStatusCode)
	{
		var stream = await response.Content.ReadAsStreamAsync();
		var payload = await JsonSerializer
			.DeserializeAsync<List<string>>(stream);

		return payload;
	}

	// This exception can be used to handle a claims challenge
	throw new WebApiMsalUiRequiredException(
		$"Unexpected status code in the HttpResponseMessage: {response.StatusCode}.", 
		response);
}

The ASP.NET Core Razor page is used to handled the WebApiMsalUiRequiredException exception. If this is returned, a new ClaimChallenge is created with the request for the authentication context. This is returned to the UI. If this response is returned, the user is redirected to authenticate again for the new scope which must fulfil the CAE policy using this.

public async Task<IActionResult> OnGet()
{
	try
	{
		Data = await _userApiClientService.GetApiDataAsync();
		return Page();
	}
	catch (WebApiMsalUiRequiredException hex)
	{
		// Challenges the user if exception is thrown from Web API.
		try
		{
			var claimChallenge = WwwAuthenticateParameters
				.GetClaimChallengeFromResponseHeaders(hex.Headers);

			_consentHandler.ChallengeUser(
				new string[] { "user.read" }, claimChallenge);

			return Page();
		}
		catch (Exception ex)
		{
			_consentHandler.HandleException(ex);
		}

		_logger.LogInformation("{hexMessage}", hex.Message);
	}

	return Page();
}

MFA is configured in a policy using the CAE conditional access authentication context.

Notes

The application will only work with Azure AD and if the continuous access evaluation policies are implemented correctly by the Azure IT tenant admin. You cannot force this in the application, you can only use this. CAE authentication context only works with Azure AD and P2 licenses. If you deploy the application anywhere else, this will not work.

Links

https://github.com/Azure-Samples/ms-identity-ca-auth-context

https://github.com/Azure-Samples/ms-identity-dotnetcore-ca-auth-context-app

https://docs.microsoft.com/en-us/azure/active-directory/conditional-access/overview

https://github.com/Azure-Samples/ms-identity-dotnetcore-daemon-graph-cae

https://docs.microsoft.com/en-us/azure/active-directory/develop/developer-guide-conditional-access-authentication-context

https://docs.microsoft.com/en-us/azure/active-directory/develop/claims-challenge

https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-conditional-access-dev-guide

https://techcommunity.microsoft.com/t5/itops-talk-blog/deep-dive-how-does-conditional-access-block-legacy/ba-p/3265345

Implement ASP.NET Core OpenID Connect OAuth PAR client with Keycloak using .NET Aspire

$
0
0

This post shows how to implement an ASP.NET Core application which uses OpenID Connect and OAuth PAR for authentication. The client application uses Keycloak as the identity provider. The Keycloak application is hosted in a docker container. The applications are run locally using .NET Aspire. This makes it really easy to develop using containers.

Code: https://github.com/damienbod/keycloak-backchannel

Setup

The standard Aspire Microsoft template was used to setup the .NET Aspire AppHost, ServiceDefaults projects. The Keycloak container service was added to the AppHost project using the Keycloak.AuthServices.Aspire.Hosting Nuget package. An ASP.NET Core Razor Page project was added as the UI client, but any project can be used like Blazor or an MVC application.

Keycloak Setup

The Keycloak Container is completely setup in the AppHost project. The Keycloak.AuthServices.Aspire.Hosting Nuget package is used to add the integration to .NET Aspire. For this to work, Docker Desktop needs to be installed in the development environment. I want to use the Keycloak preview features and initialized this using the WithArgs method. If using the Microsoft Keycloak package, the setup is almost identical.

var userName = builder.AddParameter("userName");
var password = builder.AddParameter("password", secret: true);

var keycloak = builder.AddKeycloakContainer("keycloak", 
            userName: userName, password: password, port: 8080)
    .WithArgs("--features=preview")
    .WithDataVolume()
    .RunWithHttpsDevCertificate(port: 8081);

I want to develop using HTTPS and so the Keycloak container needs to run in HTTPS as well. This was not so simple to setup, but Damien Edwards provided a solution which works great.

The RunWithHttpsDevCertificate extension method was added using his code and adapted so that the port is fixed for the HTTPS Keycloak server. This implementation requires the System.IO.Hashing Nuget package.

using System.Diagnostics;
using System.IO.Hashing;
using System.Text;

namespace Aspire.Hosting;

/// <summary>
/// Original src code:
/// https://github.com/dotnet/aspire-samples/blob/b741f5e78a86539bc9ab12cd7f4a5afea7aa54c4/samples/Keycloak/Keycloak.AppHost/HostingExtensions.cs
/// </summary>
public static class HostingExtensions
{
    /// <summary>
    /// Injects the ASP.NET Core HTTPS developer certificate into the resource via the specified environment variables when
    /// <paramref name="builder"/>.<see cref="IResourceBuilder{T}.ApplicationBuilder">ApplicationBuilder</see>.
    /// <see cref="IDistributedApplicationBuilder.ExecutionContext">ExecutionContext</see>.<see cref="DistributedApplicationExecutionContext.IsRunMode">IsRunMode</see><c> == true</c>.<br/>
    /// If the resource is a <see cref="ContainerResource"/>, the certificate files will be bind mounted into the container.
    /// </summary>
    /// <remarks>
    /// This method <strong>does not</strong> configure an HTTPS endpoint on the resource. Use <see cref="ResourceBuilderExtensions.WithHttpsEndpoint{TResource}"/> to configure an HTTPS endpoint.
    /// </remarks>
    public static IResourceBuilder<TResource> RunWithHttpsDevCertificate<TResource>(this IResourceBuilder<TResource> builder, string certFileEnv, string certKeyFileEnv)
        where TResource : IResourceWithEnvironment
    {
        const string DEV_CERT_DIR = "/dev-certs";

        if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
        {
            // Export the ASP.NET Core HTTPS development certificate & private key to PEM files, bind mount them into the container
            // and configure it to use them via the specified environment variables.
            var (certPath, _) = ExportDevCertificate(builder.ApplicationBuilder);
            var bindSource = Path.GetDirectoryName(certPath) ?? throw new UnreachableException();

            if (builder.Resource is ContainerResource containerResource)
            {
                builder.ApplicationBuilder.CreateResourceBuilder(containerResource)
                    .WithBindMount(bindSource, DEV_CERT_DIR, isReadOnly: true);
            }

            builder
                .WithEnvironment(certFileEnv, $"{DEV_CERT_DIR}/dev-cert.pem")
                .WithEnvironment(certKeyFileEnv, $"{DEV_CERT_DIR}/dev-cert.key");
        }

        return builder;
    }

    /// <summary>
    /// Configures the Keycloak container to use the ASP.NET Core HTTPS development certificate created by <c>dotnet dev-certs</c> when
    /// <paramref name="builder"/><c>.ExecutionContext.IsRunMode == true</c>.
    /// </summary>
    /// <remarks>
    /// See <see href="https://learn.microsoft.com/dotnet/core/tools/dotnet-dev-certs">https://learn.microsoft.com/dotnet/core/tools/dotnet-dev-certs</see>
    /// for more information on the <c>dotnet dev-certs</c> tool.<br/>
    /// See <see href="https://learn.microsoft.com/aspnet/core/security/enforcing-ssl#trust-the-aspnet-core-https-development-certificate-on-windows-and-macos">
    /// https://learn.microsoft.com/aspnet/core/security/enforcing-ssl</see>
    /// for more information on the ASP.NET Core HTTPS development certificate.
    /// </remarks>
    public static IResourceBuilder<KeycloakResource> RunWithHttpsDevCertificate(this IResourceBuilder<KeycloakResource> builder, int port = 8081, int targetPort = 8443)
    {
        if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
        {
            // Mount the ASP.NET Core HTTPS development certificate in the Keycloak container and configure Keycloak to it
            // via the KC_HTTPS_CERTIFICATE_FILE and KC_HTTPS_CERTIFICATE_KEY_FILE environment variables.
            builder
                .RunWithHttpsDevCertificate("KC_HTTPS_CERTIFICATE_FILE", "KC_HTTPS_CERTIFICATE_KEY_FILE")
                .WithHttpsEndpoint(port: port, targetPort: targetPort)
                .WithEnvironment("KC_HOSTNAME", "localhost")
                // Without disabling HTTP/2 you can hit HTTP 431 Header too large errors in Keycloak.
                // Related issues:
                // https://github.com/keycloak/keycloak/discussions/10236
                // https://github.com/keycloak/keycloak/issues/13933
                // https://github.com/quarkusio/quarkus/issues/33692
                .WithEnvironment("QUARKUS_HTTP_HTTP2", "false");
        }

        return builder;
    }

    private static (string, string) ExportDevCertificate(IDistributedApplicationBuilder builder)
    {
        // Exports the ASP.NET Core HTTPS development certificate & private key to PEM files using 'dotnet dev-certs https' to a temporary
        // directory and returns the path.
        // TODO: Check if we're running on a platform that already has the cert and key exported to a file (e.g. macOS) and just use those instead.
        var appNameHashBytes = XxHash64.Hash(Encoding.Unicode.GetBytes(builder.Environment.ApplicationName).AsSpan());
        var appNameHash = BitConverter.ToString(appNameHashBytes).Replace("-", "").ToLowerInvariant();
        var tempDir = Path.Combine(Path.GetTempPath(), $"aspire.{appNameHash}");
        var certExportPath = Path.Combine(tempDir, "dev-cert.pem");
        var certKeyExportPath = Path.Combine(tempDir, "dev-cert.key");

        if (File.Exists(certExportPath) && File.Exists(certKeyExportPath))
        {
            // Certificate already exported, return the path.
            return (certExportPath, certKeyExportPath);
        }
        else if (Directory.Exists(tempDir))
        {
            Directory.Delete(tempDir, recursive: true);
        }

        var exportProcess = Process.Start("dotnet", $"dev-certs https --export-path \"{certExportPath}\" --format Pem --no-password");

        var exited = exportProcess.WaitForExit(TimeSpan.FromSeconds(5));
        if (exited && File.Exists(certExportPath) && File.Exists(certKeyExportPath))
        {
            return (certExportPath, certKeyExportPath);
        }
        else if (exportProcess.HasExited && exportProcess.ExitCode != 0)
        {
            throw new InvalidOperationException($"HTTPS dev certificate export failed with exit code {exportProcess.ExitCode}");
        }
        else if (!exportProcess.HasExited)
        {
            exportProcess.Kill(true);
            throw new InvalidOperationException("HTTPS dev certificate export timed out");
        }

        throw new InvalidOperationException("HTTPS dev certificate export failed for an unknown reason");
    }
}

Note: The AppHost project must reference all the services used in the solution.

Keycloak client configuration

See the razorpagepar.json file in the git repository. This is a Keycloak export of the whole client. This can be imported and updated.

The client is configured to use PAR.

ASP.NET Core OpenID Connect client using OAuth PAR

The client application uses the standard OpenID Connect client and requires OAuth PAR for authentication. This is a new feature in .NET 9. The repo has a Razor Page OpenID Connect example as well as an MVC client sample. This would be the same for a Blazor application.

services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
    options.Authority = authConfiguration["StsServerIdentityUrl"];
    options.ClientSecret = authConfiguration["ClientSecret"];
    options.ClientId = authConfiguration["Audience"];
    options.ResponseType = "code";

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

    options.ClaimActions.Remove("amr");
    options.ClaimActions.MapJsonKey("website", "website");

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

    options.PushedAuthorizationBehavior = PushedAuthorizationBehavior.Require;

    options.TokenValidationParameters = new TokenValidationParameters
    {
        NameClaimType = JwtClaimTypes.Name,
        RoleClaimType = JwtClaimTypes.Role,
    };
});

Notes

.NET Aspire looks great and is easy to use in development. I am only learning this and must learn the details now. I have some issues using the containers and HTTPS and I don’t understand how the configuration works. I also don’t understand how this would work in production. Lots to learn.

Links

https://www.keycloak.org/

https://www.keycloak.org/server/features

https://github.com/NikiforovAll/keycloak-authorization-services-dotnet

https://openid.net/specs/openid-connect-backchannel-1_0.html

https://github.com/dotnet/aspire-samples/tree/main/samples

https://learn.microsoft.com/en-us/dotnet/aspire/get-started/aspire-overview


Implement a Geo-distance search using .NET Aspire, Elasticsearch and ASP.NET Core

$
0
0

This article shows how to implement a geo location search in an ASP.NET Core application using a LeafletJs map. The selected location can be used to find the nearest location with an Elasticsearch Geo-distance query. The Elasticsearch container and the ASP.NET Core UI application are setup for development using .NET Aspire.

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

Setup

For local development, .NET Aspire is used to setup the two services and the HTTPS connections between the services. The services are configured in the Aspire AppHost project .

The Elasticsearch client is setup as a singleton and requires the connection configuration. This can be changed, if for example an API key is used instead. The connection URL is read from the configuration as well as the secrets.

using Elastic.Clients.Elasticsearch;
using Elastic.Transport;

namespace WebGeoElasticsearch.ElasticsearchApi;

public class ElasticClientProvider
{
    private readonly ElasticsearchClient? _client = null;

    public ElasticClientProvider(IConfiguration configuration)
    {
        if (_client == null)
        {
            var settings = new ElasticsearchClientSettings(new Uri(configuration["ElasticsearchUrl"]!))
                .Authentication(new BasicAuthentication(configuration["ElasticsearchUserName"]!,
                    configuration["ElasticsearchPassword"]!));

            _client = new ElasticsearchClient(settings);
        }
    }

    public ElasticsearchClient GetClient()
    {
        if (_client != null)
        {
            return _client;
        }

        throw new Exception("Elasticsearch client not initialized");
    }
}

Create Index with mapping

The index cannot be created by adding a document because the mapping is created incorrectly using the default settings. The mapping can be created for the defined index using the Mappings extension from the Elastic.Clients.Elasticsearch Nuget package. This was added to the client project in the Aspire.Elastic.Clients.Elasticsearch package. The mapping is really simple and probably not complete for a production index, some keyword optimizations are required. The detailsCoordinates field is defined as a GeoPointProperty.

var mapping = await _client.Indices.CreateAsync<MapDetail>(IndexName, c => c
   .Mappings(map => map
       .Properties(
           new Properties<MapDetail>()
           {
                { "details", new TextProperty() },
                { "detailsCoordinates", new GeoPointProperty() },
                { "detailsType", new TextProperty() },
                { "id", new TextProperty() },
                { "information", new TextProperty() },
                { "name", new TextProperty() }
           }
       )
   )
);

The created mapping can be validated using the “IndexName”/_mapping GET request. This returns the definitions as a Json response.

https://localhost:9200/mapdetails/_mapping

Documents can be added to the Elasticsearch index using the IndexAsync method.

response = await _client.IndexAsync(dotNetGroup, IndexName, "1");

Search Query

A Geo-distance query is used to find the distance from the selected location to the different Geo points in the index. This using latitude and longitude coordinates.

public async Task<List<MapDetail>> SearchForClosestAsync(
	uint maxDistanceInMeter, 
	double centerLatitude, 
	double centerLongitude)
{
    // Bern	Lat 46.94792, Long 7.44461
    if (maxDistanceInMeter == 0)
    {
        maxDistanceInMeter = 1000000;
    }
    var searchRequest = new SearchRequest(IndexName)
    {
        Query = new GeoDistanceQuery
        {
            DistanceType = GeoDistanceType.Plane,
            Field = "detailsCoordinates",
            Distance = $"{maxDistanceInMeter}m",
            Location = GeoLocation.LatitudeLongitude(
				new LatLonGeoLocation
				{
					Lat = centerLatitude,
					Lon = centerLongitude
				})
        },
        Sort = BuildGeoDistanceSort(centerLatitude, centerLongitude)
    };

    searchRequest.ErrorTrace = true;

    _logger.LogInformation("SearchForClosestAsync: {SearchBody}", 
		searchRequest);

    var searchResponse = await _client
		.SearchAsync<MapDetail>(searchRequest);


    return searchResponse.Documents.ToList();
}

The found results are returned sorted using the Geo-distance sort. This puts the location with the smallest distance first. This is used for the map display.

private static List<SortOptions> BuildGeoDistanceSort(
	double centerLatitude, 
	double centerLongitude)
{
    var sorts = new List<SortOptions>();

    var sort = SortOptions.GeoDistance(
        new GeoDistanceSort
        {
            Field = new Field("detailsCoordinates"),
            Location = new List<GeoLocation>
            {
                GeoLocation.LatitudeLongitude(
					new LatLonGeoLocation
					{
						Lat = centerLatitude,
						Lon = centerLongitude
					})
            },
            Order = SortOrder.Asc,
            Unit = DistanceUnit.Meters
        }
    );

    sorts.Add(sort);

    return sorts;
}

Display using Leaflet.js

The ASP.NET Core displays the locations and the results of the search in a Leafletjs map component. The location closest to the center location is displayed differently. You can click around the map and test the different searches. The data used for this display is powered using the Geo-distance query.

Testing

The applications can be started using the .NET Aspire host project. One is run as a container, the other is a project. The docker container requires a Desktop docker installation on the host operating system. When the applications started, the containers need to boot up first. An optimization would remove this boot up.

Notes

Using Elasticsearch, it is very simple to create fairly complex search requests for your web applications. With a bit of experience complex reports, queries can be implemented as well. You can also use Elasticsearch aggregations to group and organize results for data analysis tools, reports. .NET Aspire makes it easy to develop locally and use HTTPS everywhere.

Links

https://www.elastic.co/guide/en/elasticsearch/reference/current/geo-point.html

https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-geo-distance-query.html

https://leafletjs.com/

https://www.elastic.co/guide/en/elasticsearch/reference/current/explicit-mapping.html

Viewing all 96 articles
Browse latest View live