This article shows how integration tests could be implemented for an ASP.NET Core application which uses EF Core and Azure Cosmos. The database tests can be run locally or in an Azure DevOps build using the Azure Cosmos emulator. XUnit is used to implement the tests.
EF Core is used to the access Azure Cosmos database. An EF Core DbContext was created to access Cosmos. This is like any EF Core context, with the DBSet definitions as required. Some Cosmos specific definitions are added using the OnModelCreating method. See the Cosmos-specific model customization for more details.
public class CosmosContext : DbContext
{
public CosmosContext(DbContextOptions<CosmosContext> options)
: base(options) { }
public DbSet<MyData> MyData { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultContainer("MyDataStore");
modelBuilder.Entity<MyData>()
.ToContainer("MyDataItems");
modelBuilder.Entity<MyData>()
.HasPartitionKey(o => o.PartitionKey);
modelBuilder.Entity<MyData>()
.Property(d => d.ETag)
.IsETagConcurrency();
}
}
The MyData class is is used to model the Cosmos documents. This has a PartitionKey and also an ETag which can be used for the Optimistic concurrency validation.
public class MyData
{
public string Id { get; set; }
public string PartitionKey { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public string ETag { get; set; }
}
The MyDataService service class is used to access the context and implement some query logic as required. I like to keep this simple and not separate the specification of the queries from the the business or the Linq statements. This reduces the amount of code and keeps the data access, business simple and makes it easy to adapt.
public class MyDataService
{
private CosmosContext _cosmosContext;
public MyDataService(CosmosContext cosmosContext)
{
_cosmosContext = cosmosContext;
}
public void EnsureCreated()
{
_cosmosContext.Database.EnsureCreated();
}
public async Task CreateAsync(MyData myData)
{
await _cosmosContext.MyData.AddAsync(myData);
await _cosmosContext.SaveChangesAsync(false);
}
public async Task<MyData> Get(string id)
{
return await _cosmosContext.MyData.FirstAsync(d => d.Id == id);
}
public async Task<IList<MyData>> NameContains(string name)
{
return await _cosmosContext.MyData
.Where(d => d.Name.Contains(name)).ToListAsync();
}
}
The ConfigureServices method adds the services required to use EF Core and Cosmos DB. The services are used in a Razor page application, but this could be any web application, ASP.NET Core API or ASP.NET Core Blazor.
The service needs to be tested. Instead of mocking away the database or using separate specifications classes as parameters, the service can be tested as one using Azure Cosmos emulator and EF Core. We used the framework tools to test our code. An EF Core in-memory database could also be used instead of the Azure Cosmos emulator. We use the emulator for these tests.
The tests are setup to add the services to the IoC and build these. The code can be run and asserted as required. To start locally in dev, the Azure Cosmos emulator needs to be started first.
using AspNetCoreCosmos.DataAccess;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Threading.Tasks;
using Xunit;
namespace AspNetCoreCosmos.DbTests
{
public class MyDataTests : IAsyncLifetime
{
private ServiceProvider _serviceProvider;
public ServiceProvider ServiceProvider { get; set; }
[Fact]
public async Task MyDataCreateAsync()
{
using (var scope = _serviceProvider.CreateScope())
{
// Arrange
var myData = new MyData
{
Id = Guid.NewGuid().ToString(),
PartitionKey = "Test",
Name = "testData",
Description = "test description"
};
var myDataService = scope.ServiceProvider.GetService<MyDataService>();
myDataService.EnsureCreated();
// Act
await myDataService.CreateAsync(myData);
var first = await myDataService.Get(myData.Id);
// Arrange
Assert.Equal(myData.Id, first.Id);
}
}
public Task InitializeAsync()
{
var serviceCollection = new ServiceCollection();
serviceCollection.AddDbContext<CosmosContext>(options =>
{
options.UseCosmos(
"AccountEndpoint=https://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==",
databaseName: "MyDataDb"
);
});
serviceCollection.AddScoped<MyDataService>();
_serviceProvider = serviceCollection.BuildServiceProvider();
return Task.CompletedTask;
}
public Task DisposeAsync()
{
return Task.CompletedTask;
}
}
}
The integration tests can be run in the Azure DevOps CI. I used a yaml file to test this and added this to my Azure DevOps build. This was a little bit tricky to setup because I did not easily find any working docs. The Microsoft.Azure.CosmosDB.Emulator is installed and started using Powershell. Then the tests can be run.
You can add the yaml pipeline to your Azure DevOps build and it will run like the triggers are defined or the Azure DevOps policies.
This works good, but you have to be careful in preparing the tests and running in parallel. Implementing the tests like this means you have less code in your application and you can still fully test all your code. A disadvantage with this approach is that the tests take longer to run compared to unit tests without the emulator.
The article shows how an ASP.NET Core application could implement a sign in and a sign out with two different Azure App registrations which could also be implemented using separate identity providers (tenants). The user of the application can decide to authenticate against either one of the Azure AD clients. The clients can also be deployed on separate Azure Active directories. Separate authentication schemes are used for both of the clients. Each client requires a scheme for the Open ID Connect sign in and the cookie session. The Azure AD client authentication is implemented using Microsoft.Identity.Web.
The clients are setup to use a non default Open ID Connect scheme and also a non default cookie scheme. After a successful authentication, the OnTokenValidated event is used to sign into the default cookie scheme using the claims principal returned from the Azure AD client. “t1” is used for the Open ID Connect scheme and “cookiet1” is used for the second scheme. No default schemes are defined. The second Azure App Registration client configuration is setup in the same way.
The AddAuthorization is used in a standard way and no default policy is defined. We would like the user to have the possibility to choose against what tenant and client to authenticate.
A third default scheme is added to keep the session after a successful authentication using the client schemes which authenticated. The identity is signed into this scheme after a successfully Azure AD authentication. The SignInAsync method is used for this in the OnTokenValidated event.
The sign in and the sign out needs custom implementations. The SignInT1 method is used to authenticate using the first client and the SignInT2 is used for the second. This can be called from the Razor page view. The CustomSignOut is used to sign out the correct schemes and redirect to the Azure AD endsession endpoint. The CustomSignOut method uses the clientId of the Azure AD configuration to sign out the correct session. This value can be read using the aud claim.
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
namespace AspNetCoreRazorMultiClients
{
[AllowAnonymous]
[Route("[controller]")]
public class CustomAccountController : Controller
{
private readonly IConfiguration _configuration;
public CustomAccountController(IConfiguration configuration)
{
_configuration = configuration;
}
[HttpGet("SignInT1")]
public IActionResult SignInT1([FromQuery] string redirectUri)
{
var scheme = "t1";
string redirect;
if (!string.IsNullOrEmpty(redirectUri) && Url.IsLocalUrl(redirectUri))
{
redirect = redirectUri;
}
else
{
redirect = Url.Content("~/")!;
}
return Challenge(new AuthenticationProperties { RedirectUri = redirect }, scheme);
}
[HttpGet("SignInT2")]
public IActionResult SignInT2([FromQuery] string redirectUri)
{
var scheme = "t2";
string redirect;
if (!string.IsNullOrEmpty(redirectUri) && Url.IsLocalUrl(redirectUri))
{
redirect = redirectUri;
}
else
{
redirect = Url.Content("~/")!;
}
return Challenge(new AuthenticationProperties { RedirectUri = redirect }, scheme);
}
[HttpGet("CustomSignOut")]
public async Task<IActionResult> CustomSignOut()
{
var aud = HttpContext.User.FindFirst("aud");
if (aud.Value == _configuration["AzureAdT1:ClientId"])
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
await HttpContext.SignOutAsync("cookiet1");
var authSignOut = new AuthenticationProperties
{
RedirectUri = "https://localhost:44348/SignoutCallbackOidc"
};
return SignOut(authSignOut, "t1");
}
else
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
await HttpContext.SignOutAsync("cookiet2");
var authSignOut = new AuthenticationProperties
{
RedirectUri = "https://localhost:44348/SignoutCallbackOidc"
};
return SignOut(authSignOut, "t2");
}
}
}
}
The _LoginPartial.cshtml Razor view can use the CustomAccount controller method to sign in or sign out. The available clients can be selected in a drop down control.
The app.settings have the Azure AD settings for each client as required.
{
"AzureAdT1": {
"Instance": "https://login.microsoftonline.com/",
"Domain": "damienbodhotmail.onmicrosoft.com",
"TenantId": "7ff95b15-dc21-4ba6-bc92-824856578fc1",
"ClientId": "46d2f651-813a-4b5c-8a43-63abcb4f692c",
"CallbackPath": "/signin-oidc/t1",
"SignedOutCallbackPath ": "/SignoutCallbackOidc"
// "ClientSecret": "add secret to the user secrets"
},
"AzureAdT2": {
"Instance": "https://login.microsoftonline.com/",
"Domain": "damienbodhotmail.onmicrosoft.com",
"TenantId": "7ff95b15-dc21-4ba6-bc92-824856578fc1",
"ClientId": "8e2b45c2-cad0-43c3-8af2-b32b73de30e4",
"CallbackPath": "/signin-oidc/t2",
"SignedOutCallbackPath ": "/SignoutCallbackOidc"
// "ClientSecret": "add secret to the user secrets"
},
When the application is started, the user can login using any client as required.
This works really good, if you don’t know which tenant is your default scheme. If you always use a default scheme with one tenant default, then you can use the multiple-authentication-schemes example like defined in the Microsoft.Identity.Web docs.
This post shows how Azure Service bus subscription for topics or consumers for a queue, or can be used inside an ASP.NET Core application. The Azure Service Bus client listens to events and needs to be started, stopped and registered to the topic to receive messages. An IHostedService is used for this.
Using an ASP.NET Core IHostedService to run Azure Service Bus subscriptions and consumers
The ServiceBusTopicSubscription class is used to setup the Azure Service bus subscription. The class uses the ServiceBusClient to set up the message handler, the ServiceBusAdministrationClient is used to implement filters and add or remove these rules. The Azure.Messaging.ServiceBus Nuget package is used to connect to the subscription.
using Azure.Messaging.ServiceBus;
using Azure.Messaging.ServiceBus.Administration;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace ServiceBusMessaging
{
public class ServiceBusTopicSubscription : IServiceBusTopicSubscription
{
private readonly IProcessData _processData;
private readonly IConfiguration _configuration;
private const string TOPIC_PATH = "mytopic";
private const string SUBSCRIPTION_NAME = "mytopicsubscription";
private readonly ILogger _logger;
private readonly ServiceBusClient _client;
private readonly ServiceBusAdministrationClient _adminClient;
private ServiceBusProcessor _processor;
public ServiceBusTopicSubscription(IProcessData processData,
IConfiguration configuration,
ILogger<ServiceBusTopicSubscription> logger)
{
_processData = processData;
_configuration = configuration;
_logger = logger;
var connectionString = _configuration.GetConnectionString("ServiceBusConnectionString");
_client = new ServiceBusClient(connectionString);
_adminClient = new ServiceBusAdministrationClient(connectionString);
}
public async Task PrepareFiltersAndHandleMessages()
{
ServiceBusProcessorOptions _serviceBusProcessorOptions = new ServiceBusProcessorOptions
{
MaxConcurrentCalls = 1,
AutoCompleteMessages = false,
};
_processor = _client.CreateProcessor(TOPIC_PATH, SUBSCRIPTION_NAME, _serviceBusProcessorOptions);
_processor.ProcessMessageAsync += ProcessMessagesAsync;
_processor.ProcessErrorAsync += ProcessErrorAsync;
await RemoveDefaultFilters().ConfigureAwait(false);
await AddFilters().ConfigureAwait(false);
await _processor.StartProcessingAsync().ConfigureAwait(false);
}
private async Task RemoveDefaultFilters()
{
try
{
var rules = _adminClient.GetRulesAsync(TOPIC_PATH, SUBSCRIPTION_NAME);
var ruleProperties = new List<RuleProperties>();
await foreach (var rule in rules)
{
ruleProperties.Add(rule);
}
foreach (var rule in ruleProperties)
{
if (rule.Name == "GoalsGreaterThanSeven")
{
await _adminClient.DeleteRuleAsync(TOPIC_PATH, SUBSCRIPTION_NAME, "GoalsGreaterThanSeven")
.ConfigureAwait(false);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex.ToString());
}
}
private async Task AddFilters()
{
try
{
var rules = _adminClient.GetRulesAsync(TOPIC_PATH, SUBSCRIPTION_NAME)
.ConfigureAwait(false);
var ruleProperties = new List<RuleProperties>();
await foreach (var rule in rules)
{
ruleProperties.Add(rule);
}
if (!ruleProperties.Any(r => r.Name == "GoalsGreaterThanSeven"))
{
CreateRuleOptions createRuleOptions = new CreateRuleOptions
{
Name = "GoalsGreaterThanSeven",
Filter = new SqlRuleFilter("goals > 7")
};
await _adminClient.CreateRuleAsync(TOPIC_PATH, SUBSCRIPTION_NAME, createRuleOptions)
.ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex.ToString());
}
}
private async Task ProcessMessagesAsync(ProcessMessageEventArgs args)
{
var myPayload = args.Message.Body.ToObjectFromJson<MyPayload>();
await _processData.Process(myPayload).ConfigureAwait(false);
await args.CompleteMessageAsync(args.Message).ConfigureAwait(false);
}
private Task ProcessErrorAsync(ProcessErrorEventArgs arg)
{
_logger.LogError(arg.Exception, "Message handler encountered an exception");
_logger.LogDebug($"- ErrorSource: {arg.ErrorSource}");
_logger.LogDebug($"- Entity Path: {arg.EntityPath}");
_logger.LogDebug($"- FullyQualifiedNamespace: {arg.FullyQualifiedNamespace}");
return Task.CompletedTask;
}
public async ValueTask DisposeAsync()
{
if (_processor != null)
{
await _processor.DisposeAsync().ConfigureAwait(false);
}
if (_client != null)
{
await _client.DisposeAsync().ConfigureAwait(false);
}
}
public async Task CloseSubscriptionAsync()
{
await _processor.CloseAsync().ConfigureAwait(false);
}
}
}
The WorkerServiceBus class implements the IHostedService interface and uses the IServiceBusTopicSubscription interface to subscribe to an Azure Service Bus topic. The StartAsync method is used to register the subscription using the RegisterOnMessageHandlerAndReceiveMessages method. The interface provides a start, and stop and a dispose. The Azure Service Bus class is controlled using this hosted service. If needed, a periodic task could be implemented to run health checks on the client or whatever.
public class WorkerServiceBus : IHostedService, IDisposable
{
private readonly ILogger<WorkerServiceBus> _logger;
private readonly IServiceBusConsumer _serviceBusConsumer;
private readonly IServiceBusTopicSubscription _serviceBusTopicSubscription;
public WorkerServiceBus(IServiceBusConsumer serviceBusConsumer,
IServiceBusTopicSubscription serviceBusTopicSubscription,
ILogger<WorkerServiceBus> logger)
{
_serviceBusConsumer = serviceBusConsumer;
_serviceBusTopicSubscription = serviceBusTopicSubscription;
_logger = logger;
}
public async Task StartAsync(CancellationToken stoppingToken)
{
_logger.LogDebug("Starting the service bus queue consumer and the subscription");
await _serviceBusConsumer.RegisterOnMessageHandlerAndReceiveMessages().ConfigureAwait(false);
await _serviceBusTopicSubscription.PrepareFiltersAndHandleMessages().ConfigureAwait(false);
}
public async Task StopAsync(CancellationToken stoppingToken)
{
_logger.LogDebug("Stopping the service bus queue consumer and the subscription");
await _serviceBusConsumer.CloseQueueAsync().ConfigureAwait(false);
await _serviceBusTopicSubscription.CloseSubscriptionAsync().ConfigureAwait(false);
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual async void Dispose(bool disposing)
{
if (disposing)
{
await _serviceBusConsumer.DisposeAsync().ConfigureAwait(false);
await _serviceBusTopicSubscription.DisposeAsync().ConfigureAwait(false);
}
}
}
The IHostedService is added to the services in the ConfigureServices method. The AddHostedService is used to initialize this. Now the Azure Service bus subscription can be managed and consume messages from the topic subscription or a queue is used.
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
var connection = Configuration.GetConnectionString("DefaultConnection");
services.AddDbContext<PayloadContext>(options =>
options.UseSqlite(connection));
services.AddSingleton<IServiceBusConsumer, ServiceBusConsumer>();
services.AddSingleton<IServiceBusTopicSubscription, ServiceBusTopicSubscription>();
services.AddSingleton<IProcessData, ProcessData>();
services.AddHostedService<WorkerServiceBus>();
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo
{
Version = "v1",
Title = "Payload API",
});
});
}
When the application is run, the messages can be sent to the topic and are received using the IHostedService Azure Service Bus subscription.
This article shows how to implement an ASP.NET Core Razor page to authenticate against Azure B2C and use Web APIs from a second ASP.NET Core application which are also protected using Azure B2C App registrations. Azure B2C uses the signin, signup user flow and allows identities to authenticate using an Azure AD single tenant. Two APIs are implemented, one for users and one for administrators. Only identities from the Azure AD tenant can use the administrator API. The authorization implementation which forces this, is supported using an ASP.NET Core policy with a handler.
The ASP.NET Core applications only use Azure B2C to authenticate and authorize. An ASP.NET Core Razor page application is used for the UI, but this can be any SPA, Blazor app or whatever the preferred tech stack is. The APIs are implemented using ASP.NET Core and this uses Azure B2C to validate and authorize the access tokens. The application accepts two different access tokens from the same Azure B2C identity provider. Each API has a separate scope from the associating Azure App registration. The Admin API uses claims specific to the Azure AD identity to authorize only Azure AD internal users. Other identities cannot use this API and this needs to be validated. The Azure B2C identity provider federates to the Azure AD single tenant App registration.
Setup Azure B2C App registrations
Three Azure App registrations were created in Azure B2C to support the setup above. Two for the APIs and one for the UI. The API Azure App registrations are standard with just a scope definition. The scope access_as_user was exposed in both and the APIs can be used for user access.
The UI Azure App registration is setup to use an Azure B2C user flow and will have access to both APIs. You need to select the options with the user flows.
Add the APIs to the permissions of the Azure app registration for the UI application.
Setup Azure AD App registration
A single tenant Azure App registration needs to be created in the Azure AD for the internal or admin users. The redirect URL for this is https://”your-tenant-specific-path”/oauth2/authresp. This will be used from an Azure B2C social login using the Open ID Connect provider. You also need to define a user secret and use this later. At present only secrets can be defined in this UI. This is problematic because the secrets have a max expiry of 2 years, if defining this in the portal.
Setup Azure B2C identity provider
A custom identity provider needs to be created to access the single tenant Azure AD for the admin identity authentication. Select the Identity providers in Azure B2C and create a new Open ID Connect custom IDP. Add the data to match the Azure App registration created in the previous step.
Setup Azure B2C user flow
Now a signin, signup user flow can be created to implement the Azure B2C authentication process and the registration process. This will allow local Azure B2C guest users and also the internal administrator users from the Azure AD tenant. The idp claim is required and idp_access_token claim if you require user data from the Azure AD identity. Add the required claims when creating the user flow. The claims can be added when creating the user flow in the User attributes and token claims section. Select the custom Open ID Connect provider and add this to the flow as well.
The user flow is now setup. The Azure App registrations can now be used to login and use either API as required. The idp and the idp_access_token are added for the Azure AD sign-in and this can be validated when using the admin API.
Implementing ASP.NET Core Razor page with Microsoft.Identity.Web
The ASP.NET Core application is secured using the Microsoft.Identity.Web and the Microsoft.Identity.Web.UI Nuget packages. These packages implement the Open ID connect clients and handles the Azure B2C specific client handling. The AddMicrosoftIdentityWebAppAuthentication method is used to add this and the AzureAdB2C configuration is defined to read the configuration from the app.settings, user secrets, key vault or whatever deployment is used. The rest is standard ASP.NET Core setup.
The Micorosoft.Identity.Web package uses the AzureAdB2C settings for the configuration. This example is using Azure B2C and the configuration for Azure B2C is different to an Azure AD configuration. The Instance MUST be set to domain of the Azure B2C tenant and the SignUpSignInPolicyId must be set to use the user flow as required. A signin, signup user flow is used here. The rest is common for be Azure AD and Azure B2C settings. The ScopeForAccessToken matches the two Azure App registrations created for the APIs.
The Admin Razor page uses the AuthorizeForScopes to authorize for the API it uses. This Razor page uses the API service to access the admin API. No authorization is implemented in the UI to validate the identity. Normally the page would be hidden if the identity is not an administrator , I left this out in so that it is easier to validate this in the API as this is only a demo.
namespace AzureB2CUI.Pages
{
[AuthorizeForScopes(Scopes = new string[] { "https://b2cdamienbod.onmicrosoft.com/5f4e8bb1-3f4e-4fc6-b03c-12169e192cd7/access_as_user" })]
public class CallAdminApiModel : PageModel
{
private readonly AdminApiOneService _apiService;
public JArray DataFromApi { get; set; }
public CallAdminApiModel(AdminApiOneService apiService)
{
_apiService = apiService;
}
public async Task OnGetAsync()
{
DataFromApi = await _apiService.GetApiDataAsync().ConfigureAwait(false);
}
}
}
The API service uses the ITokenAcquisition to get an access token for the defined scope. If the identity and the Azure App registration are authorized to access the API, then an access token is returned for the identity. This is sent using a HttpClient created using the IHttpClientFactory interface.
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 AzureB2CUI
{
public class AdminApiOneService
{
private readonly IHttpClientFactory _clientFactory;
private readonly ITokenAcquisition _tokenAcquisition;
private readonly IConfiguration _configuration;
public AdminApiOneService(IHttpClientFactory clientFactory,
ITokenAcquisition tokenAcquisition,
IConfiguration configuration)
{
_clientFactory = clientFactory;
_tokenAcquisition = tokenAcquisition;
_configuration = configuration;
}
public async Task<JArray> GetApiDataAsync()
{
var client = _clientFactory.CreateClient();
var scope = _configuration["AdminApiOne:ScopeForAccessToken"];
var accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync(new[] { scope }).ConfigureAwait(false);
client.BaseAddress = new Uri(_configuration["AdminApiOne:ApiBaseAddress"]);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var response = await client.GetAsync("adminaccess").ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
var responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
var data = JArray.Parse(responseContent);
return data;
}
throw new ApplicationException($"Status code: {response.StatusCode}, Error: {response.ReasonPhrase}");
}
}
}
Implementing the APIs with Microsoft.Identity.Web
The ASP.NET Core project implements the two separate APIs using separate authentication schemes and policies. The AddMicrosoftIdentityWebApiAuthentication method configures services for the user API using the default JWT scheme “Bearer” and the second scheme is setup for the “BearerAdmin” JWT bearer auth for the admin API. All API calls require an authenticated user which is setup in the AddControllers using a global policy. The AddAuthorization method is used to add an authorization policy for the admin API. The IsAdminHandler handler is used to fulfil the IsAdminRequirement requirement.
public void ConfigureServices(IServiceCollection services)
{
services.AddHttpClient();
services.AddOptions();
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
// IdentityModelEventSource.ShowPII = true;
services.AddMicrosoftIdentityWebApiAuthentication(
Configuration, "AzureB2CUserApi");
services.AddMicrosoftIdentityWebApiAuthentication(
Configuration, "AzureB2CAdminApi", "BearerAdmin");
services.AddControllers(options =>
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
// disabled this to test with users that have no email (no license added)
// .RequireClaim("email")
.Build();
options.Filters.Add(new AuthorizeFilter(policy));
});
services.AddSingleton<IAuthorizationHandler, IsAdminHandler>();
services.AddAuthorization(options =>
{
options.AddPolicy("IsAdminRequirementPolicy", policyIsAdminRequirement =>
{
policyIsAdminRequirement.Requirements.Add(new IsAdminRequirement());
});
});
}
The IsAdminHandler class checks for the idp claim and validates that the single tenant we require for admin identities is used to sign-in. The access token needs to be validated that the token was issued by our Azure B2C and that it has the correct scope. Since this is done in using the Microsoft.Identity.Web attributes, we don’t need to do this here.
public class IsAdminHandler : AuthorizationHandler<IsAdminRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, IsAdminRequirement requirement)
{
if (context == null)
throw new ArgumentNullException(nameof(context));
if (requirement == null)
throw new ArgumentNullException(nameof(requirement));
var claimIdentityprovider = context.User.Claims.FirstOrDefault(t => t.Type == "idp");
// check that our tenant was used to signin
if (claimIdentityprovider != null
&& claimIdentityprovider.Value ==
"https://login.microsoftonline.com/7ff95b15-dc21-4ba6-bc92-824856578fc1/v2.0")
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
The AdminAccessController class is used to provide the admin data for admin identities. The BearerAdmin scheme is required and the IsAdminRequirementPolicy policy. The access token admin scope is also validated.
[Authorize(AuthenticationSchemes = "BearerAdmin", Policy = "IsAdminRequirementPolicy")]
[AuthorizeForScopes(Scopes = new string[] { "api://5f4e8bb1-3f4e-4fc6-b03c-12169e192cd7/access_as_user" })]
[ApiController]
[Route("[controller]")]
public class AdminAccessController : ControllerBase
{
[HttpGet]
public List<string> Get()
{
string[] scopeRequiredByApi = new string[] { "access_as_user" };
HttpContext.VerifyUserHasAnyAcceptedScope(scopeRequiredByApi);
return new List<string> { "admin data" };
}
}
The user API also validates the access token, this time using the default Bearer scheme. No policy is required here, so only the default global authorization filter is used. The user API scope is validated.
[Authorize(AuthenticationSchemes = "Bearer")]
[AuthorizeForScopes(Scopes = new string[] { "api://723191f4-427e-4f77-93a8-0a62dac4e080/access_as_user" })]
[ApiController]
[Route("[controller]")]
public class UserAccessController : ControllerBase
When the application is run, the Azure B2C user flow is used to authenticate and internal or external users can sign-in, sign-up. This view can be customized to match your styles.
Admins can use the admin API and the guest users can use the user APIs.
Notes
This works but it can be improved and there are other ways to achieve this setup. If you require only a subset of identities from the Azure AD tenant, an enterprise app can be used to define the users which can use the Azure AD App registration. Or you can do this with an Azure group and assign this to the app and the users to the group.
You should also force MFA in the application for admins by validating the claims in the token and also the client ID which the token was created for. (as well as in the Azure AD tenant.)
Azure B2C is still using version one access tokens and seems and the federation to Azure AD does not use PKCE.
The Open ID Connect client requires a secret to access the Azure AD App registration. This can only be defined for a max of two years and it is not possible to use managed identities or a certificate. This means you would need to implement a secret rotation script or something so that to solution does not stop working. This is not ideal in Azure and is solved better in other IDPs. It should be possible to define long living secrets using the Powershell module and you update then with every release etc.
It would also be possible to use the Graph API to validate the identity accessing the admin API, user API.
Azure B2C API connectors could also be used to add extra claims to the tokens for usage in the application.
This article shows how to use Microsoft Graph API to send emails for a .NET Core Desktop WPF application. Microsoft.Identity.Client is used to authenticate using an Azure App registration with the required delegated scopes for the Graph API. The emails can be sent with text or html bodies and also with any file attachments uploaded in the WPF application.
Before we can send emails using Microsoft Graph API, we need to create an Azure App registration with the correct delegated scopes. In our example, the URI http://localhost:65419 is used for the AAD redirect to the browser opened by the WPF application and this is added to the authentication configuration. Once created, the client ID of the Azure App registration is used in the app settings in the application as well as the tenant ID and the scopes.
You need to add the required scopes for the Graph API to send emails. These are delegated permissions, which can be accessed using the Add a permission menu.
The Mail.Send and the Mail.ReadWrite delegated scopes from the Microsoft Graph API are added to the Azure App registration.
To add these, scroll down through the items in the App a permission, Microsoft Graph API delegated scopes menu, check the checkboxes for the Mail.Send and the Mail.ReadWrite.
Desktop Application
The Microsoft.Identity.Client and the Microsoft.Identity.Web.MicrosoftGraphBeta Nuget packages are used to authenticate and use the Graph API. You probably could use the Graph API Nuget packages directly instead of Microsoft.Identity.Web.MicrosoftGraphBeta, I used this since I normally do web and it has everything required.
The PublicClientApplicationBuilder class is used to define the redirect URL which matches the URL from the Azure App registration. The TokenCacheHelper class is the same as from the Microsoft examples.
The identity can authentication using the SignIn method. If a server session exists, a token is acquired silently otherwise an interactive flow is used.
public async Task<IAccount> SignIn()
{
try
{
var result = await AcquireTokenSilent();
return result.Account;
}
catch (MsalUiRequiredException)
{
return await AcquireTokenInteractive().ConfigureAwait(false);
}
}
private async Task<IAccount> AcquireTokenInteractive()
{
var accounts = (await _app.GetAccountsAsync()).ToList();
var builder = _app.AcquireTokenInteractive(Scopes)
.WithAccount(accounts.FirstOrDefault())
.WithUseEmbeddedWebView(false)
.WithPrompt(Microsoft.Identity.Client.Prompt.SelectAccount);
var result = await builder.ExecuteAsync().ConfigureAwait(false);
return result.Account;
}
public async Task<AuthenticationResult> AcquireTokenSilent()
{
var accounts = await GetAccountsAsync();
var result = await _app.AcquireTokenSilent(Scopes, accounts.FirstOrDefault())
.ExecuteAsync()
.ConfigureAwait(false);
return result;
}
The SendEmailAsync method uses a message object and Graph API to send the emails. If the identity has the permissions, the licenses and is authenticated, then an email will be sent using the definitions from the Message class.
public async Task SendEmailAsync(Message message)
{
var result = await AcquireTokenSilent();
_httpClient.DefaultRequestHeaders.Authorization
= new AuthenticationHeaderValue("Bearer", result.AccessToken);
_httpClient.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
GraphServiceClient graphClient = new GraphServiceClient(_httpClient)
{
AuthenticationProvider = new DelegateAuthenticationProvider(async (requestMessage) =>
{
requestMessage.Headers.Authorization
= new AuthenticationHeaderValue("Bearer", result.AccessToken);
})
};
var saveToSentItems = true;
await graphClient.Me
.SendMail(message, saveToSentItems)
.Request()
.PostAsync();
}
The EmailService class is used to added the recipient, header (subject) and the body to the message which represents the email. The attachments are added separately using the MessageAttachmentsCollectionPage class. The AddAttachment method is used to add as many attachments to the email as required which are uploaded as a base64 byte array. The service can send html bodies or text bodies.
public class EmailService
{
MessageAttachmentsCollectionPage MessageAttachmentsCollectionPage
= new MessageAttachmentsCollectionPage();
public Message CreateStandardEmail(string recipient, string header, string body)
{
var message = new Message
{
Subject = header,
Body = new ItemBody
{
ContentType = BodyType.Text,
Content = body
},
ToRecipients = new List<Recipient>()
{
new Recipient
{
EmailAddress = new EmailAddress
{
Address = recipient
}
}
},
Attachments = MessageAttachmentsCollectionPage
};
return message;
}
public Message CreateHtmlEmail(string recipient, string header, string body)
{
var message = new Message
{
Subject = header,
Body = new ItemBody
{
ContentType = BodyType.Html,
Content = body
},
ToRecipients = new List<Recipient>()
{
new Recipient
{
EmailAddress = new EmailAddress
{
Address = recipient
}
}
},
Attachments = MessageAttachmentsCollectionPage
};
return message;
}
public void AddAttachment(byte[] rawData, string filePath)
{
MessageAttachmentsCollectionPage.Add(new FileAttachment
{
Name = Path.GetFileName(filePath),
ContentBytes = EncodeTobase64Bytes(rawData)
});
}
public void ClearAttachments()
{
MessageAttachmentsCollectionPage.Clear();
}
static public byte[] EncodeTobase64Bytes(byte[] rawData)
{
string base64String = System.Convert.ToBase64String(rawData);
var returnValue = Convert.FromBase64String(base64String);
return returnValue;
}
}
Azure App Registration settings
The app settings specific to your Azure Active Directory tenant and the Azure App registration values need to be added to the app settings in the .NET Core application. The Scope configuration is set to use the required scopes required to send emails.
The WPF application provides an Azure AD login for the identity. The user of the WPF application can sign-in using a browser which redirects to the AAD authentication page. Once authenticated, the user can send a html email or a text email. The AddAttachment method uses the OpenFileDialog to upload a file in the WPF application, get the raw bytes and add these to the attachments which are sent with the next email message. Once the email is sent, the attachments are removed.
public partial class MainWindow : Window
{
AadGraphApiDelegatedClient _aadGraphApiDelegatedClient = new AadGraphApiDelegatedClient();
EmailService _emailService = new EmailService();
const string SignInString = "Sign In";
const string ClearCacheString = "Clear Cache";
public MainWindow()
{
InitializeComponent();
_aadGraphApiDelegatedClient.InitClient();
}
private async void SignIn(object sender = null, RoutedEventArgs args = null)
{
var accounts = await _aadGraphApiDelegatedClient.GetAccountsAsync();
if (SignInButton.Content.ToString() == ClearCacheString)
{
await _aadGraphApiDelegatedClient.RemoveAccountsAsync();
SignInButton.Content = SignInString;
UserName.Content = "Not signed in";
return;
}
try
{
var account = await _aadGraphApiDelegatedClient.SignIn();
Dispatcher.Invoke(() =>
{
SignInButton.Content = ClearCacheString;
SetUserName(account);
});
}
catch (MsalException ex)
{
if (ex.ErrorCode == "access_denied")
{
// The user canceled sign in, take no action.
}
else
{
// An unexpected error occurred.
string message = ex.Message;
if (ex.InnerException != null)
{
message += "Error Code: " + ex.ErrorCode + "Inner Exception : " + ex.InnerException.Message;
}
MessageBox.Show(message);
}
Dispatcher.Invoke(() =>
{
UserName.Content = "Not signed in";
});
}
}
private async void SendEmail(object sender, RoutedEventArgs e)
{
var message = _emailService.CreateStandardEmail(EmailRecipientText.Text,
EmailHeader.Text, EmailBody.Text);
await _aadGraphApiDelegatedClient.SendEmailAsync(message);
_emailService.ClearAttachments();
}
private async void SendHtmlEmail(object sender, RoutedEventArgs e)
{
var messageHtml = _emailService.CreateHtmlEmail(EmailRecipientText.Text,
EmailHeader.Text, EmailBody.Text);
await _aadGraphApiDelegatedClient.SendEmailAsync(messageHtml);
_emailService.ClearAttachments();
}
private void AddAttachment(object sender, RoutedEventArgs e)
{
var dlg = new OpenFileDialog();
if (dlg.ShowDialog() == true)
{
byte[] data = File.ReadAllBytes(dlg.FileName);
_emailService.AddAttachment(data, dlg.FileName);
}
}
private void SetUserName(IAccount userInfo)
{
string userName = null;
if (userInfo != null)
{
userName = userInfo.Username;
}
if (userName == null)
{
userName = "Not identified";
}
UserName.Content = userName;
}
}
Running the application
When the application is started, the user can sign-in using the Sign in button.
The standard Azure AD login is used in a popup browser. Once the authentication is completed, the browser redirect sends the tokens back to the application.
If a file attachment needs to be sent, the Add Attachment button can be used. This opens up a dialog and any single file can be selected.
When the email is sent successfully, the email and the file can be viewed in the recipients inbox. The emails are also saved to the senders sent emails. This can be disabled if required.
This article shows how to improve the security of an ASP.NET Core Razor Page application by adding security headers to all HTTP Razor Page responses. The security headers are added using the NetEscapades.AspNetCore.SecurityHeaders Nuget package from Andrew Lock. The headers are used to protect the session, not for authentication. The application is authenticated using Open ID Connect, the security headers are used to protected the session.
The NetEscapades.AspNetCore.SecurityHeaders and the NetEscapades.AspNetCore.SecurityHeaders.TagHelpers Nuget packages are added to the csproj file of the web application. The tag helpers are added to use the nonce from the CSP in the Razor Pages.
What each header protects against is explained really good on the securityheaders.com results view of your test. You can click different links of each validation which takes you to the documentation of each header. The links at the bottom of this post provide some excellent information about what and why of the headers.
The security header definitions are added using the HeaderPolicyCollection class. I added this to a separate class to keep the Startup class small, where the middleware is added. I passed a boolean parameter into the method which is used to add or remove the HSTS header. We might not want to add this to local development and block all non HTTPS requests to localhost.
The policy defined in this demo is for Razor Page applications with as much blocked as possible. You should be able to re-use this in your projects.
The COOP (Cross Origin Opener Policy), COEP (Cross Origin Embedder Policy), CORP (Cross Origin Resource Policy) headers are relatively new. You might need to update you existing application deployments with these. The links at the bottom of the post provide information about these headers in detail and I would recommend reading these.
In the Startup class, the UseSecurityHeaders method is used to apply the HTTP headers policy and add the middleware to the application. The env.IsDevelopment() is used to add or not to add the HSTS header. The default HSTS middleware from the ASP.NET Core templates was removed from the Configure method as this is not required.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
}
app.UseSecurityHeaders(
SecurityHeadersDefinitions
.GetHeaderPolicyCollection(env.IsDevelopment()));
The server header can be removed in the program class if using Kestrel. If using IIS, you probably need to use the web.config to remove this.
We want to apply the CSP nonce to all our scripts in the Razor Pages. We can add the NetEscapades.AspNetCore.SecurityHeaders namespace to the _ViewImports.cshtml file.
In the Razor Page _Layout, the CSP nonce can be used. I had to get the nonce from the HttpContext and add this to all scripts. This way, the scripts will be loaded. If the nonce does not match or is not applied to the script, it will not be loaded due to the CSP definition. I’ll ping Andrew Lock to see if the tag helper could be used directly.
I used ngrok to test this before I deployed the application. The Security headers scans the web application and returns a really neat summary to what headers you have and how good it finds them. Each header has a link to a excellent documentation, blog on Scott Helme‘s website https://scotthelme.co.uk
The CSP can be tested using the https://csp-evaluator.withgoogle.com from google. The CSP evaluator gives an excellent summary and also suggestions how to improve the CSP.
The Razor Page application security can be much improved by added the headers to the application. The NetEscapades.AspNetCore.SecurityHeaders Nuget package makes it incredibly easy to apply this. I will create a follow up blog with a policy definition for a Blazor application and also a for an Web API application.
Notes:
If the application is fully protected without any public views, the follow redirects checkbox on the security headers needs to be disabled as then you only get the results of the identity provider used to authenticate.
I block all traffic, if possible, which is not from my domain including sub domains. If implementing enterprise applications, I would always do this. If implementing public facing applications with high traffic volumes or need extra fast response times, or need to reduce the costs of hosting, then CDNs would need to be used, allowed and so on. Try to block all first and open up as required and maybe you can avoid some nasty surprises from all the Javascript, CSS frameworks used.
This article shows how to improve the security of an ASP.NET Core Blazor application by adding security headers to all HTTP Razor Page responses (Blazor WASM hosted in a ASP.NET Core hosted backend). The security headers are added using the NetEscapades.AspNetCore.SecurityHeaders Nuget package from Andrew Lock. The headers are used to protect the session, not for authentication. The application is authenticated using OpenID Connect, the security headers are used to protected the session. The authentication is implemented in the Blazor application using the BFF pattern. The WASM client part is just a view of the server rendered trusted backend and cookies are used in the browser. All API calls are same domain only and protected with a cookie and same site.
Improving application security in Blazor using HTTP headers – Part 2
Improving application security in an ASP.NET Core API using HTTP headers – Part 3
The NetEscapades.AspNetCore.SecurityHeaders and the NetEscapades.AspNetCore.SecurityHeaders.TagHelpers Nuget packages are added to the csproj file of the web application. The tag helpers are added to use the nonce from the CSP in the Razor Pages.
The Blazor definition is very similar to the ASP.NET Core Razor Page one. The main difference is that the CSP script policy is almost disabled due to the Blazor script requirements. We can at least force self on the content security policy header script-src definition.
The Blazor WASM logout link sends a HTTP Form POST request which is redirected to the OpenID Connect identity provider. The CSP needs to allow this redirect and the content secure policy form definition allows this.
Blazor adds the following script to the WASM host file. This means the CSP for scripts cannot be implemented in a good way.
<script>
var Module; window.__wasmmodulecallback__(); delete window.__wasmmodulecallback__;
</script>
script-src (from CSP evaluator)
‘self’ can be problematic if you host JSONP, Angular or user uploaded files. ‘unsafe-inline’ allows the execution of unsafe in-page scripts and event handlers. ‘unsafe-eval’ allows the execution of code injected into DOM APIs such as eval().
The aspnetcore-browser-refresh.js is also added for hot reload. This also prevents a strong CSP script definition in development. This could be fixed with a dev check in the policy definition. There is no point fixing this, until the wasmmodulecallback script bug is fixed.
I am following the ASP.NET Core issue and hope this can be improved for Blazor.
In the Startup class, the UseSecurityHeaders method is used to apply the HTTP headers policy and add the middleware to the application. The env.IsDevelopment() is used to add or not to add the HSTS header. The default HSTS middleware from the ASP.NET Core templates was removed from the Configure method as this is not required.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
}
app.UseSecurityHeaders(
SecurityHeadersDefinitions
.GetHeaderPolicyCollection(env.IsDevelopment(),
Configuration["AzureAd:Instance"]));
The server header can be removed in the program class file of the Blazor server project if using Kestrel. If using IIS, you probably need to use the web.config to remove this.
When we scan the https://securityheaders.com/ you can view the results. You might need to disable the authentication to check this, or provide a public view.
The content security policy has a warning due to the script definition which is required for Blazor.
If the application is fully protected without any public views, the follow redirects checkbox on the security headers needs to be disabled as then you only get the results of the identity provider used to authenticate.
I block all traffic, if possible, which is not from my domain including sub domains. If implementing enterprise applications, I would always do this. If implementing public facing applications with high traffic volumes or need extra fast response times, or need to reduce the costs of hosting, then CDNs would need to be used, allowed and so on. Try to block all first and open up as required and maybe you can avoid some nasty surprises from all the Javascript, CSS frameworks used.
Maybe until the CSP script is fixed for Blazor, you probably should avoid using Blazor for high security applications and use ASP.NET Core Razor Page applications instead.
If you use Blazor together with tokens in Azure AD or Azure B2C and this CSP script bug, you leave yourself open to having your tokens stolen. I would recommend using server authentication with Azure which removes the tokens from the browser and also solves the Azure SPA logout problem. Azure AD, Azure B2C do not support the revocation endpoint or introspection, so it is impossible to invalidate your tokens on a logout. It does not help if the IT admin, Azure monitoring can invalidate tokens using CAE.
This article shows how to improve the security of an ASP.NET Core Web API application by adding security headers to all HTTP API responses. The security headers are added using the NetEscapades.AspNetCore.SecurityHeaders Nuget package from Andrew Lock. The headers are used to protect the session, not for authorization. The application uses Microsoft.Identity.Web to authorize the API requests. The security headers are used to protected the session. Swagger is used in development and the CSP needs to be weakened to allow swagger to work during development. A strict CSP definition is used for the deployed environment.
Improving application security in an ASP.NET Core API using HTTP headers – Part 3
The NetEscapades.AspNetCore.SecurityHeaders Nuget package is added to the csproj file of the web applications. The Swagger Open API packages are added as well as the Microsoft.Identity.Web to protect the API using OAuth.
The security header definitions are added using the HeaderPolicyCollection class. I added this to a separate class to keep the Startup class small where the middleware is added. I passed a boolean parameter into the method which is used to add or remove the HSTS header and create a CSP policy depending on the environment.
The AddCspHstsDefinitions defines different policies using the parameter. In development, the HSTS header is not added to the headers and a weak CSP is used so that the Swagger UI will work. This UI uses unsafe inline Javascript and needs to be allowed in development. I remove swagger from all non dev deployments due to this and force a strong CSP definition then.
In the Startup class, the UseSecurityHeaders method is used to apply the HTTP headers policy and add the middleware to the application. The env.IsDevelopment() is used to add or not to add the HSTS header. The default HSTS middleware from the ASP.NET Core templates was removed from the Configure method as this is not required. The UseSecurityHeaders is added before the swagger middleware so that the security headers are deployment to all environments.
Running the application using a non development environment, the securtiyheaders.com check returns good results. Everything is closed as this is an API with no UI.
If a swagger UI is required, the API application can be run in the development environment. This could also be deployed if required, but in a production deployment, you probably don’t need this.
I block all traffic, if possible, which is not from my domain including sub domains. If implementing enterprise applications, I would always do this. If implementing public facing applications with high traffic volumes or need extra fast response times, or need to reduce the costs of hosting, then CDNs would need to be used, allowed and so on. Try to block all first and open up as required and maybe you can avoid some nasty surprises from all the Javascript, CSS frameworks used.
This article shows how to implement authorization in an ASP.NET Core application which uses Azure security groups for the user definitions and Azure B2C to authenticate. Microsoft Graph API is used to access the Azure group definitions for the signed in user. The client credentials flow is used to authorize the Graph API client with an application scope definition. This is not optimal, the delegated user flows would be better. By allowing applications rights for the defined scopes using Graph API, you are implicitly making the application an administrator of the tenant as well for the defined scopes.
Two Azure AD security groups were created to demonstrate this feature with Azure B2C authentication. The users were added to the admin group and the user group as required. The ASP.NET Core application uses an ASP.NET Core Razor page which should only be used by admin users, i.e. people in the group. To validate this in the application, Microsoft Graph API is used to get groups for the signed in user and an ASP.NET Core handler, requirement and policy uses the group claim created from the Azure group to force the authorization.
The groups are defined in the same tenant as the Azure B2C.
A separate Azure App registration is used to define the application Graph API scopes. The User.Read.All application scope is used. In the demo, a client secret is used, but a certificate can also be used to access the API.
The Microsoft.Graph Nuget package is used as a client for Graph API.
The GraphApiClientService class implements the Microsoft Graph API client. A ClientSecretCredential instance is used as the AuthProvider and the definitions for the client are read from the application configurations and the user secrets in development, or Azure Key Vault. The user-id from the name identifier claim is used to get the Azure groups for the signed-in user. The claim namespaces gets added using the Microsoft client, this can be deactivated if required. I usually use the default claim names but as the is an Azure IDP, I left the Microsoft defaults which adds the extra stuff to the claims. The Graph API GetMemberGroups method returns the group IDs for the signed in identity.
using Azure.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.Graph;
using System.Threading.Tasks;
namespace AzureB2CUI.Services
{
public class GraphApiClientService
{
private readonly GraphServiceClient _graphServiceClient;
public GraphApiClientService(IConfiguration configuration)
{
string[] scopes = configuration.GetValue<string>("GraphApi:Scopes")?.Split(' ');
var tenantId = configuration.GetValue<string>("GraphApi:TenantId");
// Values from app registration
var clientId = configuration.GetValue<string>("GraphApi:ClientId");
var clientSecret = configuration.GetValue<string>("GraphApi:ClientSecret");
var options = new TokenCredentialOptions
{
AuthorityHost = AzureAuthorityHosts.AzurePublicCloud
};
// https://docs.microsoft.com/dotnet/api/azure.identity.clientsecretcredential
var clientSecretCredential = new ClientSecretCredential(
tenantId, clientId, clientSecret, options);
_graphServiceClient = new GraphServiceClient(clientSecretCredential, scopes);
}
public async Task<IDirectoryObjectGetMemberGroupsCollectionPage> GetGraphApiUserMemberGroups(string userId)
{
var securityEnabledOnly = true;
return await _graphServiceClient.Users[userId]
.GetMemberGroups(securityEnabledOnly)
.Request().PostAsync()
.ConfigureAwait(false);
}
}
}
The .default scope is used to access the Graph API using the client credential client.
The user and the application are authenticated using Azure B2C and an Azure App registration. Using Azure B2C, only a certain set of claims can be returned which cannot be adapted easily. Once signed-in, we want to include the Azure security group claims in the claims principal. To do this, the Graph API is used to find the claims for the user and add the claims to the claims principal using the IClaimsTransformation implementation. This is where the GraphApiClientService is used.
using AzureB2CUI.Services;
using Microsoft.AspNetCore.Authentication;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
namespace AzureB2CUI
{
public class GraphApiClaimsTransformation : IClaimsTransformation
{
private GraphApiClientService _graphApiClientService;
public GraphApiClaimsTransformation(GraphApiClientService graphApiClientService)
{
_graphApiClientService = graphApiClientService;
}
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
ClaimsIdentity claimsIdentity = new ClaimsIdentity();
var groupClaimType = "group";
if (!principal.HasClaim(claim => claim.Type == groupClaimType))
{
var nameidentifierClaimType = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier";
var nameidentifier = principal.Claims.FirstOrDefault(t => t.Type == nameidentifierClaimType);
var groupIds = await _graphApiClientService.GetGraphApiUserMemberGroups(nameidentifier.Value);
foreach (var groupId in groupIds.ToList())
{
claimsIdentity.AddClaim(new Claim(groupClaimType, groupId));
}
}
principal.AddIdentity(claimsIdentity);
return principal;
}
The startup class adds the services and the authorization definitions for the ASP.NET Core Razor page application. The IsAdminHandlerUsingAzureGroups authorization handler is added and this is used to validate the Azure security group claim.
The IsAdminHandlerUsingAzureGroups implements the AuthorizationHandler class with the IsAdminRequirement requirement. This handler checks for the administrator group definition from the Azure tenant.
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Configuration;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace AzureB2CUI.Authz
{
public class IsAdminHandlerUsingAzureGroups : AuthorizationHandler<IsAdminRequirement>
{
private readonly string _adminGroupId;
public IsAdminHandlerUsingAzureGroups(IConfiguration configuration)
{
_adminGroupId = configuration.GetValue<string>("AzureGroups:AdminGroupId");
}
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IsAdminRequirement requirement)
{
if (context == null)
throw new ArgumentNullException(nameof(context));
if (requirement == null)
throw new ArgumentNullException(nameof(requirement));
var claimIdentityprovider = context.User.Claims.FirstOrDefault(t => t.Type == "group"
&& t.Value == _adminGroupId);
if (claimIdentityprovider != null)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
}
The policy for this can be used anywhere in the application.
[Authorize(Policy = "IsAdminPolicy")]
[AuthorizeForScopes(Scopes = new string[] { "https://b2cdamienbod.onmicrosoft.com/5f4e8bb1-3f4e-4fc6-b03c-12169e192cd7/access_as_user" })]
public class CallAdminApiModel : PageModel
{
If a user tries to call the Razor page which was created for admin users, then an Access denied is returned. Of course, in a real application, the menu for this would also be hidden if the user is not an admin and does not fulfil the policy.
If the user is an admin and a member of the Azure security group, the data and the Razor page can be opened and viewed.
By using Azure security groups, it is really easily for IT admins to add or remove users from the admin role. This can be easily managed using Powershell scripts. It is a pity that Microsoft Graph API is required to use the Azure security groups when authenticating using Azure B2C. This is much more simple to use when authenticating using Azure AD.
This article shows how to create Microsoft Teams online meetings in ASP.NET Core using Microsoft Graph. Azure AD is used to implement the authentication using Microsoft.Identity.Web and the authenticated user can create teams meetings and send emails to all participants or attendees of the meeting.
An Azure App registration is setup to authenticate against Azure AD. The ASP.NET Core application will use delegated permissions for the Microsoft Graph. The listed permissions underneath are required to create the teams meetings and to send emails to the attendees. The account used to login needs access to office and should be able to send emails.
User.Read
Mail.Send
Mail.ReadWrite
OnlineMeetings.ReadWrite
This is the list of permissions I have activate for this demo.
The Azure App registration requires a user secret or a certificate to authentication the ASP.NET Core Razor page application. Microsoft.Identity.Web uses this to authenticate the application. You should always authenticate the application if possible.
Setup ASP.NET Core application
The Microsoft.Identity.Web Nuget packages with the MicrosoftGraphBeta package are used to implement the Azure AD client. We want to implement Open ID Connect code flow with PKCE and a secret to authenticate the identity and the Microsoft packages implements this client for us.
The ConfigureServices method is used to add the required services for the Azure AD client authentication and the Microsoft Graph client for the API calls. The AddMicrosoftGraph is used to initialize the required permissions.
public void ConfigureServices(IServiceCollection services)
{
// more services ...
var scopes = "User.read Mail.Send Mail.ReadWrite OnlineMeetings.ReadWrite";
services.AddMicrosoftIdentityWebAppAuthentication(Configuration)
.EnableTokenAcquisitionToCallDownstreamApi()
.AddMicrosoftGraph("https://graph.microsoft.com/beta", scopes)
.AddInMemoryTokenCaches();
services.AddRazorPages().AddMvcOptions(options =>
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
options.Filters.Add(new AuthorizeFilter(policy));
}).AddMicrosoftIdentityUI();
}
The AzureAd configuration is read from the app.settings file. The secrets are read from the user secrets in local development.
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"Domain": "damienbodsharepoint.onmicrosoft.com",
"TenantId": "5698af84-5720-4ff0-bdc3-9d9195314244",
"ClientId": "a611a690-9f96-424f-9ea5-4ba99a642c01",
"CallbackPath": "/signin-oidc",
"SignedOutCallbackPath ": "/signout-callback-oidc"
// "ClientSecret": "add secret to the user secrets"
},
Creating a Teams meeting using Microsoft Graph
The OnlineMeeting class from Microsoft.Graph is used to create the teams meeting. In this demo, we added a begin and an end DateTime in UTC and the name (Subject) of the meeting. We want that all invited attendees can bypass the lobby and enter directly into the meeting. This is implemented with the LobbyBypassSettings property. The attendees are added to the meeting using the Upn property and setting this with the email of each attendee. The organizer is automatically set to the identity signed in.
public OnlineMeeting CreateTeamsMeeting(
string meeting, DateTimeOffset begin, DateTimeOffset end)
{
var onlineMeeting = new OnlineMeeting
{
StartDateTime = begin,
EndDateTime = end,
Subject = meeting,
LobbyBypassSettings = new LobbyBypassSettings
{
Scope = LobbyBypassScope.Everyone
}
};
return onlineMeeting;
}
public OnlineMeeting AddMeetingParticipants(
OnlineMeeting onlineMeeting, List<string> attendees)
{
var meetingAttendees = new List<MeetingParticipantInfo>();
foreach(var attendee in attendees)
{
if(!string.IsNullOrEmpty(attendee))
{
meetingAttendees.Add(new MeetingParticipantInfo
{
Upn = attendee.Trim()
});
}
}
if(onlineMeeting.Participants == null)
{
onlineMeeting.Participants = new MeetingParticipants();
};
onlineMeeting.Participants.Attendees = meetingAttendees;
return onlineMeeting;
}
A simple service is used to implement the GraphServiceClient instance which is used to send the Microsoft Graph requests. This uses the Microsoft Graph as described by the docs.
A Razor page is used to create a new Microsoft Teams online meeting. The two services are added to the class and a HTTP Post method implements the form request from the Razor page. This method creates the Microsoft Teams meeting using the services and redirects to the created Razor page with the ID of the meeting.
[AuthorizeForScopes(Scopes = new string[] { "User.read", "Mail.Send", "Mail.ReadWrite", "OnlineMeetings.ReadWrite" })]
public class CreateTeamsMeetingModel : PageModel
{
private readonly AadGraphApiDelegatedClient _aadGraphApiDelegatedClient;
private readonly TeamsService _teamsService;
public string JoinUrl { get; set; }
[BindProperty]
public DateTimeOffset Begin { get; set; }
[BindProperty]
public DateTimeOffset End { get; set; }
[BindProperty]
public string AttendeeEmail { get; set; }
[BindProperty]
public string MeetingName { get; set; }
public CreateTeamsMeetingModel(AadGraphApiDelegatedClient aadGraphApiDelegatedClient,
TeamsService teamsService)
{
_aadGraphApiDelegatedClient = aadGraphApiDelegatedClient;
_teamsService = teamsService;
}
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
return Page();
}
var meeting = _teamsService.CreateTeamsMeeting(MeetingName, Begin, End);
var attendees = AttendeeEmail.Split(';');
List<string> items = new();
items.AddRange(attendees);
var updatedMeeting = _teamsService.AddMeetingParticipants(
meeting, items);
var createdMeeting = await _aadGraphApiDelegatedClient.CreateOnlineMeeting(updatedMeeting);
JoinUrl = createdMeeting.JoinUrl;
return RedirectToPage("./CreatedTeamsMeeting", "Get", new { meetingId = createdMeeting.Id });
}
public void OnGet()
{
Begin = DateTimeOffset.UtcNow;
End = DateTimeOffset.UtcNow.AddMinutes(60);
}
}
Sending Emails to attendees using Microsoft Graph
The Created Razor page displays the meeting JoinUrl and some details of the Teams meeting. The page implements a form which can send emails to all the attendees using Microsoft Graph. The EmailService class implements the email logic to send plain mails or HTML mails using the Microsoft Graph.
using Microsoft.Graph;
using System;
using System.Collections.Generic;
using System.IO;
namespace TeamsAdminUI.GraphServices
{
public class EmailService
{
MessageAttachmentsCollectionPage MessageAttachmentsCollectionPage = new();
public Message CreateStandardEmail(string recipient, string header, string body)
{
var message = new Message
{
Subject = header,
Body = new ItemBody
{
ContentType = BodyType.Text,
Content = body
},
ToRecipients = new List<Recipient>()
{
new Recipient
{
EmailAddress = new EmailAddress
{
Address = recipient
}
}
},
Attachments = MessageAttachmentsCollectionPage
};
return message;
}
public Message CreateHtmlEmail(string recipient, string header, string body)
{
var message = new Message
{
Subject = header,
Body = new ItemBody
{
ContentType = BodyType.Html,
Content = body
},
ToRecipients = new List<Recipient>()
{
new Recipient
{
EmailAddress = new EmailAddress
{
Address = recipient
}
}
},
Attachments = MessageAttachmentsCollectionPage
};
return message;
}
public void AddAttachment(byte[] rawData, string filePath)
{
MessageAttachmentsCollectionPage.Add(new FileAttachment
{
Name = Path.GetFileName(filePath),
ContentBytes = EncodeTobase64Bytes(rawData)
});
}
public void ClearAttachments()
{
MessageAttachmentsCollectionPage.Clear();
}
static public byte[] EncodeTobase64Bytes(byte[] rawData)
{
string base64String = System.Convert.ToBase64String(rawData);
var returnValue = Convert.FromBase64String(base64String);
return returnValue;
}
}
}
The CreatedTeamsMeetingModel class is used to implement the Razor page logic to display some meeting details and send emails using a form post request. The OnGetAsync uses the meetingId to request the Teams meeting using Microsoft Graph and displays the data in the UI. The OnPostAsync method sends emails to all attendees.
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.Threading.Tasks;
using TeamsAdminUI.GraphServices;
using Microsoft.Graph;
namespace TeamsAdminUI.Pages
{
public class CreatedTeamsMeetingModel : PageModel
{
private readonly AadGraphApiDelegatedClient _aadGraphApiDelegatedClient;
private readonly EmailService _emailService;
public CreatedTeamsMeetingModel(
AadGraphApiDelegatedClient aadGraphApiDelegatedClient,
EmailService emailService)
{
_aadGraphApiDelegatedClient = aadGraphApiDelegatedClient;
_emailService = emailService;
}
[BindProperty]
public OnlineMeeting Meeting {get;set;}
[BindProperty]
public string EmailSent { get; set; }
public async Task<ActionResult> OnGetAsync(string meetingId)
{
Meeting = await _aadGraphApiDelegatedClient.GetOnlineMeeting(meetingId);
return Page();
}
public async Task<IActionResult> OnPostAsync(string meetingId)
{
Meeting = await _aadGraphApiDelegatedClient.GetOnlineMeeting(meetingId);
foreach (var attendee in Meeting.Participants.Attendees)
{
var recipient = attendee.Upn.Trim();
var message = _emailService.CreateStandardEmail(recipient, Meeting.Subject, Meeting.JoinUrl);
await _aadGraphApiDelegatedClient.SendEmailAsync(message);
}
EmailSent = "Emails sent to all attendees, please check your mailbox";
return Page();
}
}
}
The created Razor page implements the HTML display logic and adds a form to send the emails. The JoinUrl is displayed as this is what you need to open the meeting a Microsoft Teams application.
When the application is started, you can create a new Teams meeting with the required details. The logged in used must have an account with access to Office and be on the same tenant as the Azure App registration setup for the Microsoft Graph permissions. The Teams meeting is organized using the identity that signed in because we used the delegated permissions.
Once the meeting is created, the created Razor page is opened with the details. You can send an email to all attendees or use the JoinUrl directly to open up the Teams meeting.
Creating Teams meetings and sending emails in ASP.NET Core is really useful and I will do a few following up posts to this as there is so much more you can do here once this is integrated.
The article shows how an ASP.NET Core API and a Blazor BBF application can be implemented in the same project and secured using Azure AD with Microsoft.Identity.Web. The Blazor application is secured using the BFF pattern with its backend APIs protected using cookies with anti-forgery protection and same site. The API is protected using JWT Bearer tokens and used from a separate client from a different domain, not from the Blazor application. This API is not used for the Blazor application. When securing Blazor WASM hosted in an ASP.NET Core application, BFF architecture should be used for the Blazor application and not JWT tokens, especially in Azure where it is not possible to logout correctly.
The Blazor application consists of three projects. The Server project implements the OpenID Connect user interaction flow and authenticates the client as well as the user authentication. The APIs created for the Blazor WASM are protected using cookies. A second API is implemented for separate clients and the API is protected using JWT tokens. Two separate Azure App registrations are setup for the UI client and the API. If using the API, a third Azure App registration would be used for the client, for example an ASP.NET Core Razor page, or a Power App.
API
The API is implemented and protected with the MyJwtApiScheme scheme. This will be implemented later in the Startup class. The API uses swagger configurations for Open API 3 and a simple HTTP GET is implemented to validate the API security.
[Route("api/[controller]")]
[ApiController]
[Authorize(AuthenticationSchemes = "MyJwtApiScheme")]
[Produces("application/json")]
[SwaggerTag("Using to provide a public api for different clients")]
public class MyApiJwtProtectedController : ControllerBase
{
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(string))]
[SwaggerOperation(OperationId = "MyApiJwtProtected-Get",
Summary = "Returns string with details")]
public IActionResult Get()
{
return Ok("yes my public api protected with Azure AD and JWT works");
}
}
Blazor BFF
The Blazor applications are implemented using the backend for frontend security architecture. All security is implemented in the backend and the client requires a secret or a certificate to authenticate. The security data is stored to an encrypted cookie with same site protection. This is easier to secure than storing tokens in the browser storage, especially since Blazor does not support strong CSPs due to the generated Javascript and also that AAD does not support a proper logout for access tokens, refresh tokens stored in the browser. The following blog post explains this in more details.
The Microsoft.Identity.Web Nuget package is used to implement the Azure AD clients. This setup is different to the documentation. The default schemes need to be set correctly when using Cookie (App) authentication and also API Auth together. The AddMicrosoftIdentityWebApp method sets up the Blazor authentication for one Azure App registration using configuration from the AzureAd settings. The AddMicrosoftIdentityWebApi method implements the second Azure App registration for the JWT Bearer token Auth using the AzureAdMyApi settings and the MyJwtApiScheme scheme.
The ASP.NET Core project uses app.settings and user secrets in development to configure the Azure AD clients. The two Azure App registrations values are added here.
Swagger is added to make it easier to view and test the API. A simple UI is created so that you can paste your access token into the UI and test the APIs manually if required. You could also implement a user flow directly in the Swagger UI but then you would have to open up the security headers protection to allow this.
services.AddSwaggerGen(c =>
{
c.EnableAnnotations();
// 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, Array.Empty<string>() }
});
c.SwaggerDoc("v1", new OpenApiInfo
{
Title = "My API",
Version = "v1",
Description = "My API"
});
});
The Swagger middleware is added after the security headers middleware. Some people only add this to dev and not production deployments.
The UITestClientForApiTest Razor Page application can be used to login and get an access token to test the API. Before starting this application, the AzureAD configuration in the settings need to be updated to match your Azure App registration and your tenant. The access token can be used directly in the Swagger UI. The API only accepts delegated access tokens and no CC tokens etc. The configuration in the Blazor server application also needs to match the Azure App registrations in your tenant.
This setup is good for simple projects where you would like to avoid creating a second deployment or you want to re-use a small amount of business logic from the Blazor server. At some stage, it would probably make sense to split the API and the Blazor UI into two separate projects which would make this security setup more simple again but result in more infrastructure.
This article shows how to create Microsoft Teams meetings in ASP.NET Core using Microsoft Graph with application permissions. This is useful if you have a designated account to manage or create meetings, send emails or would like to provide a service for users without an office account to create meetings. This is a follow up post to part one in this series which creates Teams meetings using delegated permissions.
A simple ASP.NET Core application with no authentication was created and implements a form which creates online meetings on behalf of a designated account using Microsoft Graph with application permissions. The Microsoft Graph client uses an Azure App registration for authorization and the client credentials flow is used to authorize the client and get an access token. No user is involved in this flow and the application requires administration permissions in the Azure App registration for Microsoft Graph.
An Azure App registration is setup to authenticate against Azure AD. The ASP.NET Core application will use application permissions for the Microsoft Graph. The listed permissions underneath are required to create the Teams meetings OBO and to send emails to the attendees using the configuration email which has access to office.
Microsoft Graph application permissions:
User.Read.All
Mail.Send
Mail.ReadWrite
OnlineMeetings.ReadWrite.All
This is the list of permissions I have activate for this demo.
Configuration
The Azure AD configuration is used to get a new access token for the Microsoft Graph client and to define the email of the account which is used to create Microsoft Teams meetings and also used to send emails to the attendees. This account needs an office account.
"AzureAd": {
"TenantId": "5698af84-5720-4ff0-bdc3-9d9195314244",
"ClientId": "b9be5f88-f629-46b0-ac4c-c5a4354ac192",
// "ClientSecret": "add secret to the user secrets"
"MeetingOrganizer": "--your-email-for-sending--"
},
Setup Client credentials flow to for Microsoft Graph
A number of different ways can be used to authorize a Microsoft Graph client and is a bit confusing sometimes. Using the DefaultCredential is not really a good idea for Graph because you need to decide if you use a delegated authorization or a application authorization and the DefaultCredential will take the first one which works and this depends on the environment. For application authorization, I use the ClientSecretCredential Identity to get the service access token. This requires the .default scope and a client secret or a client credential. Using a client secret is fine if you control both client and server and the secret is stored in an Azure Key Vault. A client certificate could also be used.
private GraphServiceClient GetGraphClient()
{
string[] scopes = new[] { "https://graph.microsoft.com/.default" };
var tenantId = _configuration["AzureAd:TenantId"];
// Values from app registration
var clientId = _configuration.GetValue<string>("AzureAd:ClientId");
var clientSecret = _configuration.GetValue<string>("AzureAd:ClientSecret");
var options = new TokenCredentialOptions
{
AuthorityHost = AzureAuthorityHosts.AzurePublicCloud
};
// https://docs.microsoft.com/dotnet/api/azure.identity.clientsecretcredential
var clientSecretCredential = new ClientSecretCredential(
tenantId, clientId, clientSecret, options);
return new GraphServiceClient(clientSecretCredential, scopes);
}
The IConfidentialClientApplication interface could also be used to get access tokens which is used to authorize the Graph client. A simple in memory cache is used to store the access token. This token is reused until it expires or the application is restart. If using multiple instances, maybe a distributed cache would be better. The client uses the “https://graph.microsoft.com/.default” scope to get an access token for the Microsoft Graph client. A GraphServiceClient instance is returned with a value access token.
public class ApiTokenInMemoryClient
{
private readonly IHttpClientFactory _clientFactory;
private readonly ILogger<ApiTokenInMemoryClient> _logger;
private readonly IConfiguration _configuration;
private readonly IConfidentialClientApplication _app;
private readonly ConcurrentDictionary<string, AccessTokenItem> _accessTokens = new();
private class AccessTokenItem
{
public string AccessToken { get; set; } = string.Empty;
public DateTime ExpiresIn { get; set; }
}
public ApiTokenInMemoryClient(IHttpClientFactory clientFactory,
IConfiguration configuration, ILoggerFactory loggerFactory)
{
_clientFactory = clientFactory;
_configuration = configuration;
_logger = loggerFactory.CreateLogger<ApiTokenInMemoryClient>();
_app = InitConfidentialClientApplication();
}
public async Task<GraphServiceClient> GetGraphClient()
{
var result = await GetApiToken("default");
var httpClient = _clientFactory.CreateClient();
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", result);
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var graphClient = new GraphServiceClient(httpClient)
{
AuthenticationProvider = new DelegateAuthenticationProvider(async (requestMessage) =>
{
requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", result);
await Task.FromResult<object>(null);
})
};
return graphClient;
}
private async Task<string> GetApiToken(string api_name)
{
if (_accessTokens.ContainsKey(api_name))
{
var accessToken = _accessTokens.GetValueOrDefault(api_name);
if (accessToken.ExpiresIn > DateTime.UtcNow)
{
return accessToken.AccessToken;
}
else
{
// remove
_accessTokens.TryRemove(api_name, out _);
}
}
_logger.LogDebug($"GetApiToken new from STS for {api_name}");
// add
var newAccessToken = await AcquireTokenSilent();
_accessTokens.TryAdd(api_name, newAccessToken);
return newAccessToken.AccessToken;
}
private async Task<AccessTokenItem> AcquireTokenSilent()
{
//var scopes = "User.read Mail.Send Mail.ReadWrite OnlineMeetings.ReadWrite.All";
var authResult = await _app
.AcquireTokenForClient(scopes: new[] { "https://graph.microsoft.com/.default" })
.WithAuthority(AzureCloudInstance.AzurePublic, _configuration["AzureAd:TenantId"])
.ExecuteAsync();
return new AccessTokenItem
{
ExpiresIn = authResult.ExpiresOn.UtcDateTime,
AccessToken = authResult.AccessToken
};
}
private IConfidentialClientApplication InitConfidentialClientApplication()
{
return ConfidentialClientApplicationBuilder
.Create(_configuration["AzureAd:ClientId"])
.WithClientSecret(_configuration["AzureAd:ClientSecret"])
.Build();
}
}
OnlineMeetings Graph Service
The AadGraphApiApplicationClient service is used to send the Microsoft Graph requests. This uses the graphServiceClient client with the correct access token. The GetUserIdAsync method is used to get the Graph Id using the UPN. This is used in the Users API to run the requests with the application scopes. The Me property is not used as this is for delegated scopes. We have no user in this application. We run the requests as an application on behalf of the designated user.
public class AadGraphApiApplicationClient
{
private readonly IConfiguration _configuration;
public AadGraphApiApplicationClient(IConfiguration configuration)
{
_configuration = configuration;
}
private async Task<string> GetUserIdAsync()
{
var meetingOrganizer = _configuration["AzureAd:MeetingOrganizer"];
var filter = $"startswith(userPrincipalName,'{meetingOrganizer}')";
var graphServiceClient = GetGraphClient();
var users = await graphServiceClient.Users
.Request()
.Filter(filter)
.GetAsync();
return users.CurrentPage[0].Id;
}
public async Task SendEmailAsync(Message message)
{
var graphServiceClient = GetGraphClient();
var saveToSentItems = true;
var userId = await GetUserIdAsync();
await graphServiceClient.Users[userId]
.SendMail(message, saveToSentItems)
.Request()
.PostAsync();
}
public async Task<OnlineMeeting> CreateOnlineMeeting(OnlineMeeting onlineMeeting)
{
var graphServiceClient = GetGraphClient();
var userId = await GetUserIdAsync();
return await graphServiceClient.Users[userId]
.OnlineMeetings
.Request()
.AddAsync(onlineMeeting);
}
public async Task<OnlineMeeting> UpdateOnlineMeeting(OnlineMeeting onlineMeeting)
{
var graphServiceClient = GetGraphClient();
var userId = await GetUserIdAsync();
return await graphServiceClient.Users[userId]
.OnlineMeetings[onlineMeeting.Id]
.Request()
.UpdateAsync(onlineMeeting);
}
public async Task<OnlineMeeting> GetOnlineMeeting(string onlineMeetingId)
{
var graphServiceClient = GetGraphClient();
var userId = await GetUserIdAsync();
return await graphServiceClient.Users[userId]
.OnlineMeetings[onlineMeetingId]
.Request()
.GetAsync();
}
private GraphServiceClient GetGraphClient()
{
string[] scopes = new[] { "https://graph.microsoft.com/.default" };
var tenantId = _configuration["AzureAd:TenantId"];
// Values from app registration
var clientId = _configuration.GetValue<string>("AzureAd:ClientId");
var clientSecret = _configuration.GetValue<string>("AzureAd:ClientSecret");
var options = new TokenCredentialOptions
{
AuthorityHost = AzureAuthorityHosts.AzurePublicCloud
};
// https://docs.microsoft.com/dotnet/api/azure.identity.clientsecretcredential
var clientSecretCredential = new ClientSecretCredential(
tenantId, clientId, clientSecret, options);
return new GraphServiceClient(clientSecretCredential, scopes);
}
}
The startup class adds the services as required. No authentication is added for the ASP.NET Core application.
When the application is started, you can create a new Teams meeting with the required details. The configuration email must have an account with access to Office and be on the same tenant as the Azure App registration setup for the Microsoft Graph application permissions. The Email must have a policy setup to allow the Microsoft Graph calls. The Teams meeting is organized using the identity that signed in because we used the applications permissions.
This works really well and can be used for Azure B2C solutions as well. If possible, you should only use delegated scopes in the application, if possible. By using application permissions, the ASP.NET Core is implicitly an administrator of these permissions as well. It would be better if user accounts with delegated access was used which are managed by your IT etc.
This article shows how Azure AD verifiable credentials can be issued and used in an ASP.NET Core application. An ASP.NET Core Razor page application is used to implement the credential issuer. To issue credentials, the application must manage the credential subject data as well as require authenticated users who would like to add verifiable credentials to their digital wallet. The Microsoft Authenticator mobile application is used as the digital wallet.
Two ASP.NET Core applications are implemented to issue and verify the verifiable credentials. The credential issuer must administrate and authenticate its identities to issue verifiable credentials. A verifiable credential issuer should never issue credentials to unauthenticated subjects of the credential. As the verifier normally only authorizes the credential, it is important to know that the credentials were at least issued correctly. We do not know as a verifier who or and mostly what sends the verifiable credentials but at least we know that the credentials are valid if we trust the issuer. It is possible to use private holder binding for a holder of a wallet which would increase the trust between the verifier and the issued credentials.
The credential issuer in this demo issues credentials for driving licenses using Azure AD verifiable credentials. The ASP.NET Core application uses Microsoft.Identity.Web to authenticate all identities. In a real application, the application would be authenticated as well requiring 2FA for all users. Azure AD supports this good. The administrators would also require admin rights, which could be implemented using Azure security groups or Azure roles which are added to the application as claims after the OIDC authentication flow.
Any authenticated identity can request credentials (A driving license in this demo) for themselves and no one else. The administrators can create data which is used as the subject, but not issue credentials for others.
Azure AD verifiable credential setup
Azure AD verifiable credentials is setup using the Azure Docs for the Rest API and the Azure verifiable credential ASP.NET Core sample application.
Following the documentation, a display file and a rules file were uploaded for the verifiable credentials created for this issuer. In this demo, two credential subjects are defined to hold the data when issuing or verifying the credentials.
{
"default": {
"locale": "en-US",
"card": {
"title": "National Driving License VC",
"issuedBy": "Damienbod",
"backgroundColor": "#003333",
"textColor": "#ffffff",
"logo": {
"uri": "https://raw.githubusercontent.com/swiss-ssi-group/TrinsicAspNetCore/main/src/NationalDrivingLicense/wwwroot/ndl_car_01.png",
"description": "National Driving License Logo"
},
"description": "Use your verified credential to prove to anyone that you can drive."
},
"consent": {
"title": "Do you want to get your Verified Credential?",
"instructions": "Sign in with your account to get your card."
},
"claims": {
"vc.credentialSubject.name": {
"type": "String",
"label": "Name"
},
"vc.credentialSubject.details": {
"type": "String",
"label": "Details"
}
}
}
}
The rules file defines the attestations for the credentials. Two standard claims are used to hold the data, the given_name and the family_name. These claims are mapped to our name and details subject claims and holds all the data. Adding custom claims to Azure AD or Azure B2C is not so easy and so I decided for the demo, it would be easier to use standard claims which works without custom configurations. The data sent from the issuer to the holder of the claims can be sent in the application. It should be possible to add credential subject properties without requiring standard AD id_token claims, but I was not able to set this up in the current preview version.
The rest of the Azure AD credentials are setup exactly like the documentation.
Administration of the Driving licenses
The verifiable credential issuer application uses a Razor page application which accesses a Microsoft SQL Azure database using Entity Framework Core to access the database. The administrator of the credentials can assign driving licenses to any user. The DrivingLicenseDbContext class is used to define the DBSet for driver licenses.
A DriverLicense entity contains the infomation we use to create verifiable credentials.
public class DriverLicense
{
[Key]
public Guid Id { get; set; }
public string UserName { get; set; } = string.Empty;
public DateTimeOffset IssuedAt { get; set; }
public string Name { get; set; } = string.Empty;
public string FirstName { get; set; } = string.Empty;
public DateTimeOffset DateOfBirth { get; set; }
public string Issuedby { get; set; } = string.Empty;
public bool Valid { get; set; }
public string DriverLicenseCredentials { get; set; } = string.Empty;
public string LicenseType { get; set; } = string.Empty;
}
Issuing credentials to authenticated identities
When issuing verifiable credentials using Azure AD Rest API, an IssuanceRequestPayload payload is used to request the credentials which are to be issued to the digital wallet. Verifiable credentials are issued to a digital wallet. The credentials are issued for the holder of the wallet. The payload classes are the same for all API implementations apart from the CredentialsClaims class which contains the subject claims which match the rules file of your definition.
public class IssuanceRequestPayload
{
[JsonPropertyName("includeQRCode")]
public bool IncludeQRCode { get; set; }
[JsonPropertyName("callback")]
public Callback Callback { get; set; } = new Callback();
[JsonPropertyName("authority")]
public string Authority { get; set; } = string.Empty;
[JsonPropertyName("registration")]
public Registration Registration { get; set; } = new Registration();
[JsonPropertyName("issuance")]
public Issuance Issuance { get; set; } = new Issuance();
}
public class Callback
{
[JsonPropertyName("url")]
public string Url { get; set; } = string.Empty;
[JsonPropertyName("state")]
public string State { get; set; } = string.Empty;
[JsonPropertyName("headers")]
public Headers Headers { get; set; } = new Headers();
}
public class Headers
{
[JsonPropertyName("api-key")]
public string ApiKey { get; set; } = string.Empty;
}
public class Registration
{
[JsonPropertyName("clientName")]
public string ClientName { get; set; } = string.Empty;
}
public class Issuance
{
[JsonPropertyName("type")]
public string CredentialsType { get; set; } = string.Empty;
[JsonPropertyName("manifest")]
public string Manifest { get; set; } = string.Empty;
[JsonPropertyName("pin")]
public Pin Pin { get; set; } = new Pin();
[JsonPropertyName("claims")]
public CredentialsClaims Claims { get; set; } = new CredentialsClaims();
}
public class Pin
{
[JsonPropertyName("value")]
public string Value { get; set; } = string.Empty;
[JsonPropertyName("length")]
public int Length { get; set; } = 4;
}
/// Application specific claims used in the payload of the issue request.
/// When using the id_token for the subject claims, the IDP needs to add the values to the id_token!
/// The claims can be mapped to anything then.
public class CredentialsClaims
{
/// <summary>
/// attribute names need to match a claim from the id_token
/// </summary>
[JsonPropertyName("given_name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("family_name")]
public string Details { get; set; } = string.Empty;
}
The GetIssuanceRequestPayloadAsync method sets the data for each identity that requested the credentials. Only a signed in user can request the credentials for themselves. The context.User.Identity is used and the data is selected from the database for the signed in user. It is important that credentials are only issued to authenticated users. Users and the application must be authenticated correctly using 2FA and so on. Per default, the credentials are only authorized on the verifier which is probably not enough for most security flows.
The IssuanceRequestAsync method gets the payload data and request credentials from the Azure AD verifiable credentials REST API and returns this value which can be scanned using a QR code in the Razor page. The request returns fast. Depending on how the flow continues, a web hook in the application will update the status in a cache. This cache is persisted and polled from the UI. This could be improved by using SignalR.
[HttpGet("/api/issuer/issuance-request")]
public async Task<ActionResult> IssuanceRequestAsync()
{
try
{
var payload = await _issuerService.GetIssuanceRequestPayloadAsync(Request, HttpContext);
try
{
var (Token, Error, ErrorDescription) = await _issuerService.GetAccessToken();
if (string.IsNullOrEmpty(Token))
{
_log.LogError($"failed to acquire accesstoken: {Error} : {ErrorDescription}");
return BadRequest(new { error = Error, error_description = ErrorDescription });
}
var defaultRequestHeaders = _httpClient.DefaultRequestHeaders;
defaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Token);
HttpResponseMessage res = await _httpClient.PostAsJsonAsync(
_credentialSettings.ApiEndpoint, payload);
var response = await res.Content.ReadFromJsonAsync<IssuanceResponse>();
if(response == null)
{
return BadRequest(new { error = "400", error_description = "no response from VC API"});
}
if (res.StatusCode == HttpStatusCode.Created)
{
_log.LogTrace("succesfully called Request API");
if (payload.Issuance.Pin.Value != null)
{
response.Pin = payload.Issuance.Pin.Value;
}
response.Id = payload.Callback.State;
var cacheData = new CacheData
{
Status = IssuanceConst.NotScanned,
Message = "Request ready, please scan with Authenticator",
Expiry = response.Expiry.ToString()
};
_cache.Set(payload.Callback.State, JsonSerializer.Serialize(cacheData));
return Ok(response);
}
else
{
_log.LogError("Unsuccesfully called Request API");
return BadRequest(new { error = "400", error_description = "Something went wrong calling the API: " + response });
}
}
catch (Exception ex)
{
return BadRequest(new { error = "400", error_description = "Something went wrong calling the API: " + ex.Message });
}
}
catch (Exception ex)
{
return BadRequest(new { error = "400", error_description = ex.Message });
}
}
The IssuanceResponse is returned to the UI.
public class IssuanceResponse
{
[JsonPropertyName("requestId")]
public string RequestId { get; set; } = string.Empty;
[JsonPropertyName("url")]
public string Url { get; set; } = string.Empty;
[JsonPropertyName("expiry")]
public int Expiry { get; set; }
[JsonPropertyName("pin")]
public string Pin { get; set; } = string.Empty;
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
}
The IssuanceCallback is used as a web hook for the Azure AD verifiable credentials. When developing or deploying, this web hook needs to have a public IP. I use ngrok to test this. Because the issuer authenticates the identities using an Azure App registration, everytime the ngrok URL changes, the redirect URL needs to be updated. Each callback request updates the cache. This API also needs to allow anonymous requests if the rest of the application is authenticated using OIDC. The AllowAnonymous attribute is required, if you use an authenticated ASP.NET Core application.
[AllowAnonymous]
[HttpPost("/api/issuer/issuanceCallback")]
public async Task<ActionResult> IssuanceCallback()
{
string content = await new System.IO.StreamReader(Request.Body).ReadToEndAsync();
var issuanceResponse = JsonSerializer.Deserialize<IssuanceCallbackResponse>(content);
try
{
//there are 2 different callbacks. 1 if the QR code is scanned (or deeplink has been followed)
//Scanning the QR code makes Authenticator download the specific request from the server
//the request will be deleted from the server immediately.
//That's why it is so important to capture this callback and relay this to the UI so the UI can hide
//the QR code to prevent the user from scanning it twice (resulting in an error since the request is already deleted)
if (issuanceResponse.Code == IssuanceConst.RequestRetrieved)
{
var cacheData = new CacheData
{
Status = IssuanceConst.RequestRetrieved,
Message = "QR Code is scanned. Waiting for issuance...",
};
_cache.Set(issuanceResponse.State, JsonSerializer.Serialize(cacheData));
}
if (issuanceResponse.Code == IssuanceConst.IssuanceSuccessful)
{
var cacheData = new CacheData
{
Status = IssuanceConst.IssuanceSuccessful,
Message = "Credential successfully issued",
};
_cache.Set(issuanceResponse.State, JsonSerializer.Serialize(cacheData));
}
if (issuanceResponse.Code == IssuanceConst.IssuanceError)
{
var cacheData = new CacheData
{
Status = IssuanceConst.IssuanceError,
Payload = issuanceResponse.Error?.Code,
//at the moment there isn't a specific error for incorrect entry of a pincode.
//So assume this error happens when the users entered the incorrect pincode and ask to try again.
Message = issuanceResponse.Error?.Message
};
_cache.Set(issuanceResponse.State, JsonSerializer.Serialize(cacheData));
}
return Ok();
}
catch (Exception ex)
{
return BadRequest(new { error = "400", error_description = ex.Message });
}
}
The IssuanceCallbackResponse is returned to the UI.
public class IssuanceCallbackResponse
{
[JsonPropertyName("code")]
public string Code { get; set; } = string.Empty;
[JsonPropertyName("requestId")]
public string RequestId { get; set; } = string.Empty;
[JsonPropertyName("state")]
public string State { get; set; } = string.Empty;
[JsonPropertyName("error")]
public CallbackError? Error { get; set; }
}
The IssuanceResponse method is polled from a Javascript client in the Razor page UI. This method updates the status in the UI using the cache and the database.
[HttpGet("/api/issuer/issuance-response")]
public ActionResult IssuanceResponse()
{
try
{
//the id is the state value initially created when the issuance request was requested from the request API
//the in-memory database uses this as key to get and store the state of the process so the UI can be updated
string state = this.Request.Query["id"];
if (string.IsNullOrEmpty(state))
{
return BadRequest(new { error = "400", error_description = "Missing argument 'id'" });
}
CacheData value = null;
if (_cache.TryGetValue(state, out string buf))
{
value = JsonSerializer.Deserialize<CacheData>(buf);
Debug.WriteLine("check if there was a response yet: " + value);
return new ContentResult { ContentType = "application/json", Content = JsonSerializer.Serialize(value) };
}
return Ok();
}
catch (Exception ex)
{
return BadRequest(new { error = "400", error_description = ex.Message });
}
}
The DriverLicenseCredentialsModel class is used for the credential issuing for the sign-in user. The HTML part of the Razor page contains the Javascript client code which was implemented using the code from the Microsoft Azure sample.
public class DriverLicenseCredentialsModel : PageModel
{
private readonly DriverLicenseService _driverLicenseService;
public string DriverLicenseMessage { get; set; } = "Loading credentials";
public bool HasDriverLicense { get; set; } = false;
public DriverLicense DriverLicense { get; set; }
public DriverLicenseCredentialsModel(DriverLicenseService driverLicenseService)
{
_driverLicenseService = driverLicenseService;
}
public async Task OnGetAsync()
{
DriverLicense = await _driverLicenseService.GetDriverLicense(HttpContext.User.Identity.Name);
if (DriverLicense != null)
{
DriverLicenseMessage = "Add your driver license credentials to your wallet";
HasDriverLicense = true;
}
else
{
DriverLicenseMessage = "You have no valid driver license";
}
}
}
Testing and running the applications
Ngrok is used to provide a public callback for the Azure AD verifiable credentials callback. When the application is started, you need to create a driving license. This is done in the administration Razor page. Once a driving license exists, the View driver license Razor page can be used to issue a verifiable credential to the logged in user. A QR Code is displayed which can be scanned to begin the issue flow.
Using the Microsoft authenticator, you can scan the QR Code and add the verifiable credentials to your digital wallet. The credentials can now be used in any verifier which supports the Microsoft Authenticator wallet. The verify ASP.NET Core application can be used to verify and used the issued verifiable credential from the Wallet.
This article shows how scheduled tasks can be implemented in ASP.NET Core using Quartz.NET and then displays the job info in an ASP.NET Core Razor page using SignalR. A concurrent job and a non concurrent job are implemented using a simple trigger to show the difference in how the jobs are run. Quartz.NET provides lots of scheduling features and has an easy to use API for implementing scheduled jobs.
A simple ASP.NET Core Razor Page web application is used to implement the scheduler and the SignalR messaging. The Quartz Nuget package and the Quartz.Extensions.Hosting Nuget package are used to implement the scheduling service. The Microsoft.AspNetCore.SignalR.Client package is used to send messages to all listening web socket clients.
The .NET 6 templates no longer use a Startup class, all this logic can now be implemented directly in the Program.cs file with no static main. The ConfigureServices logic can be implemented using a WebApplicationBuilder instance. The AddQuartz method is used to add the scheduling services. Two jobs are added, a concurrent job and a non concurrent job. Both jobs are triggered with a simple trigger every five seconds which runs forever. The AddQuartzHostedService method adds the service as a hosted service. The AddSignalR adds the SignalR services.
using AspNetCoreQuartz;
using AspNetCoreQuartz.QuartzServices;
using Quartz;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
builder.Services.AddSignalR();
builder.Services.AddQuartz(q =>
{
q.UseMicrosoftDependencyInjectionJobFactory();
var conconcurrentJobKey = new JobKey("ConconcurrentJob");
q.AddJob<ConconcurrentJob>(opts => opts.WithIdentity(conconcurrentJobKey));
q.AddTrigger(opts => opts
.ForJob(conconcurrentJobKey)
.WithIdentity("ConconcurrentJob-trigger")
.WithSimpleSchedule(x => x
.WithIntervalInSeconds(5)
.RepeatForever()));
var nonConconcurrentJobKey = new JobKey("NonConconcurrentJob");
q.AddJob<NonConconcurrentJob>(opts => opts.WithIdentity(nonConconcurrentJobKey));
q.AddTrigger(opts => opts
.ForJob(nonConconcurrentJobKey)
.WithIdentity("NonConconcurrentJob-trigger")
.WithSimpleSchedule(x => x
.WithIntervalInSeconds(5)
.RepeatForever()));
});
builder.Services.AddQuartzHostedService(
q => q.WaitForJobsToComplete = true);
The WebApplication instance is used to add the middleware like the Startup Configure method. The SignalR JobsHub endpoint is added to send the live messages of the running jobs to the UI in the client browser..
The ConconcurrentJob implements the IJob interface and logs messages before and after a time delay. A SignalR client is used to send all the job information to any listening clients. A seven second sleep was added to simulate a slow running job. The jobs are triggered every 5 seconds, so this should result in no change in behavior as the jobs can run in parallel.
using Microsoft.AspNetCore.SignalR;
using Quartz;
namespace AspNetCoreQuartz.QuartzServices
{
public class ConconcurrentJob : IJob
{
private readonly ILogger<ConconcurrentJob> _logger;
private static int _counter = 0;
private readonly IHubContext<JobsHub> _hubContext;
public ConconcurrentJob(ILogger<ConconcurrentJob> logger,
IHubContext<JobsHub> hubContext)
{
_logger = logger;
_hubContext = hubContext;
}
public async Task Execute(IJobExecutionContext context)
{
var count = _counter++;
var beginMessage = $"Conconcurrent Job BEGIN {count} {DateTime.UtcNow}";
await _hubContext.Clients.All.SendAsync("ConcurrentJobs", beginMessage);
_logger.LogInformation(beginMessage);
Thread.Sleep(7000);
var endMessage = $"Conconcurrent Job END {count} {DateTime.UtcNow}";
await _hubContext.Clients.All.SendAsync("ConcurrentJobs", endMessage);
_logger.LogInformation(endMessage);
}
}
}
The NonConconcurrentJob class is almost like the previous job, except the DisallowConcurrentExecution attribute is used to prevent concurrent running of the job. This means that even though the trigger is set to five seconds, each job must wait until the previous job finishes.
[DisallowConcurrentExecution]
public class NonConconcurrentJob : IJob
{
private readonly ILogger<NonConconcurrentJob> _logger;
private static int _counter = 0;
private readonly IHubContext<JobsHub> _hubContext;
public NonConconcurrentJob(ILogger<NonConconcurrentJob> logger,
IHubContext<JobsHub> hubContext)
{
_logger = logger;
_hubContext = hubContext;
}
public async Task Execute(IJobExecutionContext context)
{
var count = _counter++;
var beginMessage = $"NonConconcurrentJob Job BEGIN {count} {DateTime.UtcNow}";
await _hubContext.Clients.All.SendAsync("NonConcurrentJobs", beginMessage);
_logger.LogInformation(beginMessage);
Thread.Sleep(7000);
var endMessage = $"NonConconcurrentJob Job END {count} {DateTime.UtcNow}";
await _hubContext.Clients.All.SendAsync("NonConcurrentJobs", endMessage);
_logger.LogInformation(endMessage);
}
}
The JobsHub class implements the SignalR Hub and define methods for sending SignalR messages. Two messages are used, one for the concurrent job messages and one for the non concurrent job messages.
public class JobsHub : Hub
{
public Task SendConcurrentJobsMessage(string message)
{
return Clients.All.SendAsync("ConcurrentJobs", message);
}
public Task SendNonConcurrentJobsMessage(string message)
{
return Clients.All.SendAsync("NonConcurrentJobs", message);
}
}
The microsoft signalr Javascript package is used to implement the client which listens for messages.
The SignalR client adds the two methods to listen to messages sent from the Quartz jobs.
const connection = new signalR.HubConnectionBuilder()
.withUrl("/jobshub")
.configureLogging(signalR.LogLevel.Information)
.build();
async function start() {
try {
await connection.start();
console.log("SignalR Connected.");
} catch (err) {
console.log(err);
setTimeout(start, 5000);
}
};
connection.onclose(async () => {
await start();
});
start();
connection.on("ConcurrentJobs", function (message) {
var li = document.createElement("li");
document.getElementById("concurrentJobs").appendChild(li);
li.textContent = `${message}`;
});
connection.on("NonConcurrentJobs", function (message) {
var li = document.createElement("li");
document.getElementById("nonConcurrentJobs").appendChild(li);
li.textContent = `${message}`;
});
When the application is run and the hosted Quartz service runs the scheduled jobs, the concurrent jobs starts every five seconds as required and the non concurrent job runs every seven seconds due to the thread sleep. Running concurrent or non concurrent jobs by using a single attribute definition is a really powerful feature of Quartz.NET.
Quartz.NET provides great documentation and has a really simple API. By using SignalR, it would be really easy to implement a good monitoring UI.
This post shows how to implement an ASP.NET Core Razor Page application which authenticates using Azure B2C and uses custom claims implemented using the Azure B2C API connector. The claims provider is implemented using an ASP.NET Core API application and the Azure API connector requests the data from this API. The Azure API connector adds the claims after an Azure B2C sign in flow or whatever settings you configured in the Azure B2C user flow.
An Azure App registration is setup for the ASP.NET Core Razor page application. A client secret is used to authenticate the client. The redirect URI is added for the app. This is a standard implementation.
Setup the API connector
The API connector is setup to add the extra claims after a sign in. This defines the API endpoint and the authentication method. Only Basic or certificate authentication is possible for this API service. Both of these are not ideal for implementing and using this service to add extra claims to the identity. I started ngrok using the cmd and used the URL from this to configure Azure B2C API connector. Maybe two separate connectors could be setup for a solution, one like this for development and a second one with the Azure App service host address and certificate authentication used.
Azure B2C user attribute
The custom claims are added to the Azure B2C user attributes. The custom claims can be add as required.
Setup to Azure B2C user flow
The Azure B2C user flow is configured to used the API connector. This flow adds the application claims to the token which it receives from the API call used in the API connector.
The custom claims are added then using the application claims blade. This is required if the custom claims are to be added.
I also added the custom claims to the Azure B2C user flow user attributes.
Azure B2C is now setup to use the custom claims and the data for these claims will be set used the API connector service.
ASP.NET Core Razor Page
The ASP.NET Core Razor Page uses Microsoft.Identity.Web to authenticate using Azure B2C. This is a standard setup for a B2C user flow.
The main difference between an Azure B2C user flow and an Azure AD authentication is the configuration. The SignUpSignInPolicyId is set to match the configured Azure B2C user flow and the Instance uses the b2clogin from the domain unlike the AAD configuration definition.
The index Razor page returns the claims and displays the values in the UI.
public class IndexModel : PageModel
{
[BindProperty]
public IEnumerable<Claim> Claims { get; set; } = Enumerable.Empty<Claim>();
public void OnGet()
{
Claims = User.Claims;
}
}
This is all the end user application requires, there is no special setup here.
ASP.NET Core API connector implementation
The API implemented for the Azure API connector uses a HTTP Post. Basic authentication is used to validate the request as well as the client ID which needs to match the configured App registration. This is weak authentication and should not be used in production especially since the API provides sensitive PII data. If the request provides the correct credentials and the correct client ID, the data is returned for the email. In this demo, the email is returned in the custom claim. Normal the data would be returned using some data store or whatever.
[HttpPost]
public async Task<IActionResult> PostAsync()
{
// Check HTTP basic authorization
if (!IsAuthorized(Request))
{
_logger.LogWarning("HTTP basic authentication validation failed.");
return Unauthorized();
}
string content = await new System.IO.StreamReader(Request.Body).ReadToEndAsync();
var requestConnector = JsonSerializer.Deserialize<RequestConnector>(content);
// If input data is null, show block page
if (requestConnector == null)
{
return BadRequest(new ResponseContent("ShowBlockPage", "There was a problem with your request."));
}
string clientId = _configuration["AzureAdB2C:ClientId"];
if (!clientId.Equals(requestConnector.ClientId))
{
_logger.LogWarning("HTTP clientId is not authorized.");
return Unauthorized();
}
// If email claim not found, show block page. Email is required and sent by default.
if (requestConnector.Email == null || requestConnector.Email == "" || requestConnector.Email.Contains("@") == false)
{
return BadRequest(new ResponseContent("ShowBlockPage", "Email name is mandatory."));
}
var result = new ResponseContent
{
// use the objectId of the email to get the user specfic claims
MyCustomClaim = $"everything awesome {requestConnector.Email}"
};
return Ok(result);
}
private bool IsAuthorized(HttpRequest req)
{
string username = _configuration["BasicAuthUsername"];
string password = _configuration["BasicAuthPassword"];
// Check if the HTTP Authorization header exist
if (!req.Headers.ContainsKey("Authorization"))
{
_logger.LogWarning("Missing HTTP basic authentication header.");
return false;
}
// Read the authorization header
var auth = req.Headers["Authorization"].ToString();
// Ensure the type of the authorization header id `Basic`
if (!auth.StartsWith("Basic "))
{
_logger.LogWarning("HTTP basic authentication header must start with 'Basic '.");
return false;
}
// Get the the HTTP basinc authorization credentials
var cred = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(auth.Substring(6))).Split(':');
// Evaluate the credentials and return the result
return (cred[0] == username && cred[1] == password);
}
The ResponseContent class is used to return the data for the identity. All custom claims must be prefixed with the extension_ The data is then added to the profile data.
public class ResponseContent
{
public const string ApiVersion = "1.0.0";
public ResponseContent()
{
Version = ApiVersion;
Action = "Continue";
}
public ResponseContent(string action, string userMessage)
{
Version = ApiVersion;
Action = action;
UserMessage = userMessage;
if (action == "ValidationError")
{
Status = "400";
}
}
[JsonPropertyName("version")]
public string Version { get; }
[JsonPropertyName("action")]
public string Action { get; set; }
[JsonPropertyName("userMessage")]
public string? UserMessage { get; set; }
[JsonPropertyName("status")]
public string? Status { get; set; }
[JsonPropertyName("extension_MyCustomClaim")]
public string MyCustomClaim { get; set; } = string.Empty;
}
}
With this, custom claims can be added to Azure B2C identities. This can be really useful when for example implementing verifiable credentials using id_tokens. This is much more complicated to implement compared to other IDPs but at least it is possible and can be solved. The technical solution to secure the API has room for improvements.
Testing
The applications can be started and the API connector needs to be mapped to a public IP. After starting the apps, start ngrok with a matching configuration for the HTTP address of the API connector API.
ngrok http https://localhost:5002
This URL in the API connector configured on Azure needs to match this ngrok URL. all good, the applications will run and the custom claim will be displayed in the UI.
Notes
The profile data in this API is very sensitive and you should use maximal security protections which are possible. Using Basic authentication alone for this type of API is not a good idea. It would be great to see managed identities supported or something like this. I used basic authentication so that I could use ngrok to demo the feature, we need a public endpoint for testing. I would not use this in a productive deployment. I would use certificate authentication with an Azure App service deployment and the certificate created and deployed using Azure Key Vault. Certificate rotation would have to be setup. I am not sure how good API connector infrastructure automation can be implemented, I have not tried this yet. A separate security solution would need to be implemented for local development. This is all a bit messy as all these extra steps end up in costs or developers taking short cuts and deploying with less security.
This article shows how an ASP.NET Core API can be setup to require certificates for authentication. The API is used to implement an Azure B2C API connector service. The API connector client uses a certificate to request profile data from the Azure App Service API implementation, which is validated using the certificate thumbprint.
An Azure App Service was created which uses .NET and 64 bit configurations. The Azure App Service is configured to require incoming client certificates and will forward this to the application. By configuring this, any valid certificate will work. The certificate still needs to be validated inside the application. You need to check that the correct client certificate is being used.
Implement the API with certificate authentication for deployment
The AddAuthentication sets the default scheme to CertificateAuthentication. The AddCertificate method adds the required configuration to validate the client certificates used with each request. We use a self signed certificate for the authentication. If a valid certificate is used, the MyCertificateValidationService is used to validate that it is also the correct certificate.
The middleware services are setup so that in development no authentication is used and the requests are validated using basic authentication. If the environment in not development, certificate authentication is used and all API calls require authorization.
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
if (!app.Environment.IsDevelopment())
{
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers().RequireAuthorization();
}
else
{
app.UseAuthorization();
app.MapControllers();
}
app.Run();
The MyCertificateValidationService validates the certificate. This checks if the certificate used has the correct thumbprint and is the same as the certificate used in the client application, in these case the Azure B2C API connector.
public class MyCertificateValidationService
{
private readonly ILogger<MyCertificateValidationService> _logger;
public MyCertificateValidationService(ILogger<MyCertificateValidationService> logger)
{
_logger = logger;
}
public bool ValidateCertificate(X509Certificate2 clientCertificate)
{
return CheckIfThumbprintIsValid(clientCertificate);
}
private bool CheckIfThumbprintIsValid(X509Certificate2 clientCertificate)
{
var listOfValidThumbprints = new List<string>
{
// add thumbprints of your allowed clients
"15D118271F9AE7855778A2E6A00A575341D3D904"
};
if (listOfValidThumbprints.Contains(clientCertificate.Thumbprint))
{
_logger.LogInformation($"Custom auth-success for certificate {clientCertificate.FriendlyName} {clientCertificate.Thumbprint}");
return true;
}
_logger.LogWarning($"auth failed for certificate {clientCertificate.FriendlyName} {clientCertificate.Thumbprint}");
return false;
}
}
Setup Azure B2C API connector with certification authentication
The Azure B2C API connector is setup to use a certificate. You can create the certificate anyway you want. I used the CertificateManager Nuget package to create a RSA 512 certificate with a 3072 key size. The thumbprint from this certificate needs to be validated in the ASP.NET Core API application.
The Azure B2C API connector is added to the Azure B2C user flow. The use flow requires all the custom claims to be defined and the values can be set in the API Connector service. See the first post in this blog group for details.
Creating an RSA 512 with a 3072 size key
You can create certificates using .NET Core using the CertificateManager Nuget package which provides some helper methods for creating the X509 certificates as required.
class Program
{
static CreateCertificates _cc;
static void Main(string[] args)
{
var builder = new ConfigurationBuilder()
.AddUserSecrets<Program>();
var configuration = builder.Build();
var sp = new ServiceCollection()
.AddCertificateManager()
.BuildServiceProvider();
_cc = sp.GetService<CreateCertificates>();
var rsaCert = CreateRsaCertificateSha512KeySize2048("localhost", 10);
string password = configuration["certificateSecret"];
var iec = sp.GetService<ImportExportCertificate>();
var rsaCertPfxBytes = iec.ExportSelfSignedCertificatePfx(password, rsaCert);
File.WriteAllBytes("cert_rsa512.pfx", rsaCertPfxBytes);
Console.WriteLine("created");
}
public static X509Certificate2 CreateRsaCertificateSha512KeySize2048(string dnsName, int validityPeriodInYears)
{
var basicConstraints = new BasicConstraints
{
CertificateAuthority = false,
HasPathLengthConstraint = false,
PathLengthConstraint = 0,
Critical = false
};
var subjectAlternativeName = new SubjectAlternativeName
{
DnsName = new List<string>
{
dnsName,
}
};
var x509KeyUsageFlags = X509KeyUsageFlags.DigitalSignature;
// only if certification authentication is used
var enhancedKeyUsages = new OidCollection
{
OidLookup.ClientAuthentication,
// OidLookup.ServerAuthentication
// OidLookup.CodeSigning,
// OidLookup.SecureEmail,
// OidLookup.TimeStamping
};
var certificate = _cc.NewRsaSelfSignedCertificate(
new DistinguishedName { CommonName = dnsName },
basicConstraints,
new ValidityPeriod
{
ValidFrom = DateTimeOffset.UtcNow,
ValidTo = DateTimeOffset.UtcNow.AddYears(validityPeriodInYears)
},
subjectAlternativeName,
enhancedKeyUsages,
x509KeyUsageFlags,
new RsaConfiguration
{
KeySize = 3072,
HashAlgorithmName = HashAlgorithmName.SHA512
});
return certificate;
}
}
Running the applications
I setup two user flows for running and testing the applications. One is using ngrok and local development with basic authentication. The second is using certification authentication and the deployed Azure App service. I published the API to the App service and run the UI application. When the user signs in, the API connector is used to get the extra custom claims from the deployed API and is returned.
This article shows how Zero Knowledge Proofs BBS+ verifiable credentials can be used to verify credential subject data from two separate verifiable credentials implemented in ASP.NET Core and MATTR. The ZKP BBS+ verifiable credentials are issued and stored on a digital wallet using a Self-Issued Identity Provider (SIOP) and OpenID Connect. A compound proof presentation template is created to verify the user data in a single verify.
Implement Compound Proof BBS+ verifiable credentials using ASP.NET Core and MATTR
What are ZKP BBS+ verifiable credentials
BBS+ verifiable credentials are built using JSON-LD and makes it possible to support selective disclosure of subject claims from a verifiable credential, compound proofs of different VCs, zero knowledge proofs where the subject claims do not need to be exposed to verify something, private holder binding and prevent tracking. The specification and implementation are still a work in progress.
Setup
The solution is setup to issue and verify the BBS+ verifiable credentials. The credential issuers are implemented in ASP.NET Core as well as the verifiable credential verifier. One credential issuer implements a BBS+ JSON-LD E-ID verifiable credential using SIOP together with Auth0 as the identity provider and the MATTR API which implements the access to the ledger and implements the logic for creating and verifying the verifiable credential and implementing the SSI specifications. The second credential issuer implements a county of residence BBS+ verifiable credential issuer like the first one. The ASP.NET Core verifier project uses a BBS+ verify presentation to verify that a user has the correct E-ID credentials and the county residence verifiable credentials in one request. This is presented as a compound proof using credential subject data from both verifiable credentials. The credentials are presented from the MATTR wallet to the ASP.NET Core verifier application.
The BBS+ compound proof is made up from the two verifiable credentials stored on the wallet. The holder of the wallet owns the credentials and can be trusted to a fairly high level because SIOP was used to add the credentials to the MATTR wallet which requires a user authentication on the wallet using OpenID Connect. If the host system has strong authentication, the user of the wallet is probably the same person for which the credentials where intended for and issued too. We only can prove that the verifiable credentials are valid, we cannot prove that the person sending the credentials is also the subject of the credentials or has the authorization to act on behalf of the credential subject. With SIOP, we know that the credentials were issued in a way which allows for strong authentication.
Implementing the Credential Issuers
The credentials are created using a credential issuer and can be added to the users wallet using SIOP. An ASP.NET Core application is used to implement the MATTR API client for creating and issuing the credentials. Auth0 is used for the OIDC server and the profiles used in the verifiable credentials are added here. The Auth0 server is part of the credential issuer service business. The application has two separate flows for administrators and users, or holders of the credentials and credential issuer administrators.
An administrator can signin to the credential issuer ASP.NET Core application using OIDC and can create new OIDC credential issuers using BBS+. Once created, the callback URL for the credential issuer needs to be added to the Auth0 client application as a redirect URL.
A user can login to the ASP.NET Core application and request the verifiable credentials only for themselves. This is not authenticated on the ASP.NET Core application, but on the wallet application using the SIOP flow. The application presents a QR Code which starts the flow. Once authenticated, the credentials are added to the digital wallet. Both the E-ID and the county of residence credentials are added and stored on the wallet.
Auth0 Auth pipeline rules
The credential subject claims added to the verifiable credential uses the profile data from the Auth0 identity provider. This data can be added using an Auth0 auth pipeline rule. Once defined, if the user has the profile data, the verifiable credentials can be created from the data.
For more information on adding BBS+ verifiable credentials using MATTR, see the documentation, or a previous blog in this series.
Verifying the compound proof BBS+ verifiable credential
The verifier application needs to use both E-ID and county of residence verifiable credentials. This is done using a presentation template which is specific to the MATTR platform. Once created, a verify request is created using this template and presented to the user in the UI as a QR code. The holder of the wallet can scan this code and the verification begins. The wallet will use the verification request and try to find the credentials on the wallet which matches what was requested. If the wallet has the data from the correct issuers, the holder of the wallet consents, the data is sent to the verifier application using a new presentation verifiable credential using the credential subject data from both of the existing verifiable credentials stored on the wallet. The webhook or an API on the verifier application handles this and validates the request. If all is good, the data is persisted and the UI is updated using SignalR messaging.
Creating a verifier presentation template
Before verifier presentations can be sent a the digital wallet, a template needs to be created in the MATTR platform. The CreatePresentationTemplate Razor page is used to create a new template. The template requires the two DIDs used for issuing the credentials from the credential issuer applications.
public class CreatePresentationTemplateModel : PageModel
{
private readonly MattrPresentationTemplateService _mattrVerifyService;
public bool CreatingPresentationTemplate { get; set; } = true;
public string TemplateId { get; set; }
[BindProperty]
public PresentationTemplate PresentationTemplate { get; set; }
public CreatePresentationTemplateModel(MattrPresentationTemplateService mattrVerifyService)
{
_mattrVerifyService = mattrVerifyService;
}
public void OnGet()
{
PresentationTemplate = new PresentationTemplate();
}
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
return Page();
}
TemplateId = await _mattrVerifyService.CreatePresentationTemplateId(
PresentationTemplate.DidEid, PresentationTemplate.DidCountyResidence);
CreatingPresentationTemplate = false;
return Page();
}
}
public class PresentationTemplate
{
[Required]
public string DidEid { get; set; }
[Required]
public string DidCountyResidence { get; set; }
}
The MattrPresentationTemplateService class implements the logic required to create a new presentation template. The service gets a new access token for your MATTR tenant and creates a new template using the credential subjects required and the correct contexts. BBS+ and frames require specific contexts. The CredentialQuery2 has two separate Frame items, one for each verifiable credential created and stored on the digital wallet.
public class MattrPresentationTemplateService
{
private readonly IHttpClientFactory _clientFactory;
private readonly MattrTokenApiService _mattrTokenApiService;
private readonly VerifyEidCountyResidenceDbService _verifyEidAndCountyResidenceDbService;
private readonly MattrConfiguration _mattrConfiguration;
public MattrPresentationTemplateService(IHttpClientFactory clientFactory,
IOptions<MattrConfiguration> mattrConfiguration,
MattrTokenApiService mattrTokenApiService,
VerifyEidCountyResidenceDbService VerifyEidAndCountyResidenceDbService)
{
_clientFactory = clientFactory;
_mattrTokenApiService = mattrTokenApiService;
_verifyEidAndCountyResidenceDbService = VerifyEidAndCountyResidenceDbService;
_mattrConfiguration = mattrConfiguration.Value;
}
public async Task<string> CreatePresentationTemplateId(string didEid, string didCountyResidence)
{
// create a new one
var v1PresentationTemplateResponse = await CreateMattrPresentationTemplate(didEid, didCountyResidence);
// save to db
var template = new EidCountyResidenceDataPresentationTemplate
{
DidEid = didEid,
DidCountyResidence = didCountyResidence,
TemplateId = v1PresentationTemplateResponse.Id,
MattrPresentationTemplateReponse = JsonConvert.SerializeObject(v1PresentationTemplateResponse)
};
await _verifyEidAndCountyResidenceDbService.CreateEidAndCountyResidenceDataTemplate(template);
return v1PresentationTemplateResponse.Id;
}
private async Task<V1_PresentationTemplateResponse> CreateMattrPresentationTemplate(string didId, string didCountyResidence)
{
HttpClient client = _clientFactory.CreateClient();
var accessToken = await _mattrTokenApiService.GetApiToken(client, "mattrAccessToken");
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", accessToken);
client.DefaultRequestHeaders.TryAddWithoutValidation("Content-Type", "application/json");
var v1PresentationTemplateResponse = await CreateMattrPresentationTemplate(client, didId, didCountyResidence);
return v1PresentationTemplateResponse;
}
private async Task<V1_PresentationTemplateResponse> CreateMattrPresentationTemplate(
HttpClient client, string didEid, string didCountyResidence)
{
// create presentation, post to presentations templates api
// https://learn.mattr.global/tutorials/verify/presentation-request-template
// https://learn.mattr.global/tutorials/verify/presentation-request-template#create-a-privacy-preserving-presentation-request-template-for-zkp-enabled-credentials
var createPresentationsTemplatesUrl = $"https://{_mattrConfiguration.TenantSubdomain}/v1/presentations/templates";
var eidAdditionalPropertiesCredentialSubject = new Dictionary<string, object>();
eidAdditionalPropertiesCredentialSubject.Add("credentialSubject", new EidDataCredentialSubject
{
Explicit = true
});
var countyResidenceAdditionalPropertiesCredentialSubject = new Dictionary<string, object>();
countyResidenceAdditionalPropertiesCredentialSubject.Add("credentialSubject", new CountyResidenceDataCredentialSubject
{
Explicit = true
});
var additionalPropertiesCredentialQuery = new Dictionary<string, object>();
additionalPropertiesCredentialQuery.Add("required", true);
var additionalPropertiesQuery = new Dictionary<string, object>();
additionalPropertiesQuery.Add("type", "QueryByFrame");
additionalPropertiesQuery.Add("credentialQuery", new List<CredentialQuery2> {
new CredentialQuery2
{
Reason = "Please provide your E-ID",
TrustedIssuer = new List<TrustedIssuer>{
new TrustedIssuer
{
Required = true,
Issuer = didEid // DID used to create the oidc
}
},
Frame = new Frame
{
Context = new List<object>{
"https://www.w3.org/2018/credentials/v1",
"https://w3id.org/security/bbs/v1",
"https://mattr.global/contexts/vc-extensions/v1",
"https://schema.org",
"https://w3id.org/vc-revocation-list-2020/v1"
},
Type = "VerifiableCredential",
AdditionalProperties = eidAdditionalPropertiesCredentialSubject
},
AdditionalProperties = additionalPropertiesCredentialQuery
},
new CredentialQuery2
{
Reason = "Please provide your Residence data",
TrustedIssuer = new List<TrustedIssuer>{
new TrustedIssuer
{
Required = true,
Issuer = didCountyResidence // DID used to create the oidc
}
},
Frame = new Frame
{
Context = new List<object>{
"https://www.w3.org/2018/credentials/v1",
"https://w3id.org/security/bbs/v1",
"https://mattr.global/contexts/vc-extensions/v1",
"https://schema.org",
"https://w3id.org/vc-revocation-list-2020/v1"
},
Type = "VerifiableCredential",
AdditionalProperties = countyResidenceAdditionalPropertiesCredentialSubject
},
AdditionalProperties = additionalPropertiesCredentialQuery
}
});
var payload = new MattrOpenApiClient.V1_CreatePresentationTemplate
{
Domain = _mattrConfiguration.TenantSubdomain,
Name = "zkp-eid-county-residence-compound",
Query = new List<Query>
{
new Query
{
AdditionalProperties = additionalPropertiesQuery
}
}
};
var payloadJson = JsonConvert.SerializeObject(payload);
var uri = new Uri(createPresentationsTemplatesUrl);
using (var content = new StringContentWithoutCharset(payloadJson, "application/json"))
{
var presentationTemplateResponse = await client.PostAsync(uri, content);
if (presentationTemplateResponse.StatusCode == System.Net.HttpStatusCode.Created)
{
var v1PresentationTemplateResponse = JsonConvert
.DeserializeObject<MattrOpenApiClient.V1_PresentationTemplateResponse>(
await presentationTemplateResponse.Content.ReadAsStringAsync());
return v1PresentationTemplateResponse;
}
var error = await presentationTemplateResponse.Content.ReadAsStringAsync();
}
throw new Exception("whoops something went wrong");
}
}
public class EidDataCredentialSubject
{
[Newtonsoft.Json.JsonProperty("@explicit", Required = Newtonsoft.Json.Required.Always)]
public bool Explicit { get; set; }
[Newtonsoft.Json.JsonProperty("family_name", Required = Newtonsoft.Json.Required.Always)]
[System.ComponentModel.DataAnnotations.Required]
public object FamilyName { get; set; } = new object();
[Newtonsoft.Json.JsonProperty("given_name", Required = Newtonsoft.Json.Required.Always)]
[System.ComponentModel.DataAnnotations.Required]
public object GivenName { get; set; } = new object();
[Newtonsoft.Json.JsonProperty("date_of_birth", Required = Newtonsoft.Json.Required.Always)]
[System.ComponentModel.DataAnnotations.Required]
public object DateOfBirth { get; set; } = new object();
[Newtonsoft.Json.JsonProperty("birth_place", Required = Newtonsoft.Json.Required.Always)]
[System.ComponentModel.DataAnnotations.Required]
public object BirthPlace { get; set; } = new object();
[Newtonsoft.Json.JsonProperty("height", Required = Newtonsoft.Json.Required.Always)]
[System.ComponentModel.DataAnnotations.Required]
public object Height { get; set; } = new object();
[Newtonsoft.Json.JsonProperty("nationality", Required = Newtonsoft.Json.Required.Always)]
[System.ComponentModel.DataAnnotations.Required]
public object Nationality { get; set; } = new object();
[Newtonsoft.Json.JsonProperty("gender", Required = Newtonsoft.Json.Required.Always)]
[System.ComponentModel.DataAnnotations.Required]
public object Gender { get; set; } = new object();
}
public class CountyResidenceDataCredentialSubject
{
[Newtonsoft.Json.JsonProperty("@explicit", Required = Newtonsoft.Json.Required.Always)]
public bool Explicit { get; set; }
[Newtonsoft.Json.JsonProperty("family_name", Required = Newtonsoft.Json.Required.Always)]
[System.ComponentModel.DataAnnotations.Required]
public object FamilyName { get; set; } = new object();
[Newtonsoft.Json.JsonProperty("given_name", Required = Newtonsoft.Json.Required.Always)]
[System.ComponentModel.DataAnnotations.Required]
public object GivenName { get; set; } = new object();
[Newtonsoft.Json.JsonProperty("date_of_birth", Required = Newtonsoft.Json.Required.Always)]
[System.ComponentModel.DataAnnotations.Required]
public object DateOfBirth { get; set; } = new object();
[Newtonsoft.Json.JsonProperty("address_country", Required = Newtonsoft.Json.Required.Always)]
[System.ComponentModel.DataAnnotations.Required]
public object AddressCountry { get; set; } = new object();
[Newtonsoft.Json.JsonProperty("address_locality", Required = Newtonsoft.Json.Required.Always)]
[System.ComponentModel.DataAnnotations.Required]
public object AddressLocality { get; set; } = new object();
[Newtonsoft.Json.JsonProperty("address_region", Required = Newtonsoft.Json.Required.Always)]
[System.ComponentModel.DataAnnotations.Required]
public object AddressRegion { get; set; } = new object();
[Newtonsoft.Json.JsonProperty("street_address", Required = Newtonsoft.Json.Required.Always)]
[System.ComponentModel.DataAnnotations.Required]
public object StreetAddress { get; set; } = new object();
[Newtonsoft.Json.JsonProperty("postal_code", Required = Newtonsoft.Json.Required.Always)]
[System.ComponentModel.DataAnnotations.Required]
public object PostalCode { get; set; } = new object();
}
When the presentation template is created, the following JSON payload in returned. This is what is used to create verifier presentation requests. The context must contain the value of the context value of the credentials on the wallet. You can also verify that the trusted issuer matches and that the two Frame objects are created correctly with the required values.
The presentation template is ready and can be used now. This is just a specific definition used by the MATTR platform. This is not saved to the ledger.
Creating a verifier request and present QR Code
Now that we have a presentation template, we initialize a verifier presentation request and present this as a QR Code for the holder of the digital wallet to scan. The CreateVerifyCallback method creates the verification and returns a signed token which is added to the QR Code to scan and the challengeId is encoded in base64 as we use this in the URL to request or handle the webhook callback.
public class CreateVerifierDisplayQrCodeModel : PageModel
{
private readonly MattrCredentialVerifyCallbackService _mattrCredentialVerifyCallbackService;
public bool CreatingVerifier { get; set; } = true;
public string QrCodeUrl { get; set; }
[BindProperty]
public string ChallengeId { get; set; }
[BindProperty]
public string Base64ChallengeId { get; set; }
[BindProperty]
public CreateVerifierDisplayQrCodeCallbackUrl CallbackUrlDto { get; set; }
public CreateVerifierDisplayQrCodeModel(MattrCredentialVerifyCallbackService mattrCredentialVerifyCallbackService)
{
_mattrCredentialVerifyCallbackService = mattrCredentialVerifyCallbackService;
}
public void OnGet()
{
CallbackUrlDto = new CreateVerifierDisplayQrCodeCallbackUrl();
CallbackUrlDto.CallbackUrl = $"https://{HttpContext.Request.Host.Value}";
}
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
return Page();
}
var result = await _mattrCredentialVerifyCallbackService
.CreateVerifyCallback(CallbackUrlDto.CallbackUrl);
CreatingVerifier = false;
var walletUrl = result.WalletUrl.Trim();
ChallengeId = result.ChallengeId;
var valueBytes = Encoding.UTF8.GetBytes(ChallengeId);
Base64ChallengeId = Convert.ToBase64String(valueBytes);
VerificationRedirectController.WalletUrls.Add(Base64ChallengeId, walletUrl);
// https://learn.mattr.global/tutorials/verify/using-callback/callback-e-to-e#redirect-urls
//var qrCodeUrl = $"didcomm://{walletUrl}";
QrCodeUrl = $"didcomm://https://{HttpContext.Request.Host.Value}/VerificationRedirect/{Base64ChallengeId}";
return Page();
}
}
public class CreateVerifierDisplayQrCodeCallbackUrl
{
[Required]
public string CallbackUrl { get; set; }
}
The CreateVerifyCallback method uses the host as the base URL for the callback definition which is included in the verification. An access token is requested for the MATTR API, this is used for all the requests. The last issued template is used in the verification. A new DID is created or the existing DID for this verifier is used to attach the verify presentation on the ledger. The InvokePresentationRequest is used to initialize the verification presentation. This request uses the templateId, the callback URL and the DID. Part of the body payload of the response of the request is signed and this is returned to the Razor page to be displayed as part of the QR code. This signed token is longer and so a didcomm redirect is used in the QR Code and not the value directly in the Razor page..
/// <summary>
/// https://learn.mattr.global/tutorials/verify/using-callback/callback-e-to-e
/// </summary>
/// <param name="callbackBaseUrl"></param>
/// <returns></returns>
public async Task<(string WalletUrl, string ChallengeId)> CreateVerifyCallback(string callbackBaseUrl)
{
callbackBaseUrl = callbackBaseUrl.Trim();
if (!callbackBaseUrl.EndsWith('/'))
{
callbackBaseUrl = $"{callbackBaseUrl}/";
}
var callbackUrlFull = $"{callbackBaseUrl}{MATTR_CALLBACK_VERIFY_PATH}";
var challenge = GetEncodedRandomString();
HttpClient client = _clientFactory.CreateClient();
var accessToken = await _mattrTokenApiService.GetApiToken(client, "mattrAccessToken");
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", accessToken);
client.DefaultRequestHeaders.TryAddWithoutValidation("Content-Type", "application/json");
var template = await _VerifyEidAndCountyResidenceDbService.GetLastPresentationTemplate();
var didToVerify = await _mattrCreateDidService.GetDidOrCreate("did_for_verify");
// Request DID from ledger
V1_GetDidResponse did = await RequestDID(didToVerify.Did, client);
// Invoke the Presentation Request
var invokePresentationResponse = await InvokePresentationRequest(
client,
didToVerify.Did,
template.TemplateId,
challenge,
callbackUrlFull);
// Sign and Encode the Presentation Request body
var signAndEncodePresentationRequestBodyResponse = await SignAndEncodePresentationRequestBody(
client, did, invokePresentationResponse);
// fix strange DTO
var jws = signAndEncodePresentationRequestBodyResponse.Replace("\"", "");
// save to db
var vaccinationDataPresentationVerify = new EidCountyResidenceDataPresentationVerify
{
DidEid = template.DidEid,
DidCountyResidence = template.DidCountyResidence,
TemplateId = template.TemplateId,
CallbackUrl = callbackUrlFull,
Challenge = challenge,
InvokePresentationResponse = JsonConvert.SerializeObject(invokePresentationResponse),
Did = JsonConvert.SerializeObject(did),
SignAndEncodePresentationRequestBody = jws
};
await _VerifyEidAndCountyResidenceDbService.CreateEidAndCountyResidenceDataPresentationVerify(vaccinationDataPresentationVerify);
var walletUrl = $"https://{_mattrConfiguration.TenantSubdomain}/?request={jws}";
return (walletUrl, challenge);
}
The QR Code is displayed in the UI.
Once the QR Code is created and scanned, the SignalR client starts listening for messages returned for the challengeId.
After the holder of the digital wallet has given consent, the wallet sends the verifiable credential data back to the verifier application in a HTTP request. This is sent to a webhook or an API in the verifier application. This needs to be verified correctly. In this demo, only the challengeId is used to match the request, the payload is not validated which it should be. The callback handler stores the data to the database and sends a SignalR message to inform the waiting client that the verify has been completed successfully.
The VerifiedUser ASP.NET Core Razor page displays the data after a successful verification. This uses the challengeId to get the data from the database and display this in the UI for the next steps.
public class VerifiedUserModel : PageModel
{
private readonly VerifyEidCountyResidenceDbService _verifyEidCountyResidenceDbService;
public VerifiedUserModel(VerifyEidCountyResidenceDbService verifyEidCountyResidenceDbService)
{
_verifyEidCountyResidenceDbService = verifyEidCountyResidenceDbService;
}
public string Base64ChallengeId { get; set; }
public EidCountyResidenceVerifiedClaimsDto VerifiedEidCountyResidenceDataClaims { get; private set; }
public async Task OnGetAsync(string base64ChallengeId)
{
// user query param to get challenge id and display data
if (base64ChallengeId != null)
{
var valueBytes = Convert.FromBase64String(base64ChallengeId);
var challengeId = Encoding.UTF8.GetString(valueBytes);
var verifiedDataUser = await _verifyEidCountyResidenceDbService.GetVerifiedUser(challengeId);
VerifiedEidCountyResidenceDataClaims = new EidCountyResidenceVerifiedClaimsDto
{
// Common
DateOfBirth = verifiedDataUser.DateOfBirth,
FamilyName = verifiedDataUser.FamilyName,
GivenName = verifiedDataUser.GivenName,
// E-ID
BirthPlace = verifiedDataUser.BirthPlace,
Height = verifiedDataUser.Height,
Nationality = verifiedDataUser.Nationality,
Gender = verifiedDataUser.Gender,
// County Residence
AddressCountry = verifiedDataUser.AddressCountry,
AddressLocality = verifiedDataUser.AddressLocality,
AddressRegion = verifiedDataUser.AddressRegion,
StreetAddress = verifiedDataUser.StreetAddress,
PostalCode = verifiedDataUser.PostalCode
};
}
}
}
The demo UI displays the data after a successful verification. The next steps of the verifier process can be implemented using these values. This would typically included creating an account and setting up an authentication which is not subject to phishing for high security or at least which has a second factor.
Notes
The MATTR BBS+ verifiable credentials look really good and supports selective disclosure and compound proofs. The implementation is still a WIP and MATTR are investing in this at present and will hopefully complete and improve all the BBS+ features. Until BBS+ is implemented by the majority of SSI platform providers and the specs are completed, I don’t not see how SSI can be adopted unless of course all converge on some other standard. This would help improve some of the interop problems between the vendors.
This article shows how to use Microsoft Graph with delegated permissions in a Blazor WASM ASP.NET Core hosted application. The application uses Microsoft.Identity.Web and the BFF architecture to authenticate against Azure AD. All security logic is implemented in the trusted backend. Microsoft Graph is used to access mailbox settings, teams presence and a users calendar.
A Blazor WASM UI hosted in ASP.NET Core is used to access any users mailbox settings, team presence or calendar data in the same tenant. The application uses Azure AD for authentication. Blazorise is used in the Blazor WASM UI client project. An authenticated user can enter the target email to view the required data. Delegated Microsoft Graph permissions are used to authorize the API calls.
Setup Azure App registration
The Azure App registration is setup to allow the Microsoft Graph delegated permissions to access the mailbox settings, the teams presence data and the calendar data. The mail permissions are also added if you would like to send emails using Microsoft Graph. The application is a trusted server rendered one and can keep a secret. Because of this, the app is authenticated using a secret or a certificate. You should always authenticate the application if possible.
Implement the Blazor WASM ASP.NET Core Hosted authentication
Only one application exists for the UI and the backend and so only one Azure app registration is used. All authentication is implemented in the trusted backend. The BFF security architecture is used. Microsoft.Identity.Web is used in the the trusted backend to authenticate the application and the identity. No authentication is implemented in the Blazor WASM. This is a view of the server rendered application. The security architecture is more simple and no sensitive data is stored in the client browser. This is especially important since Azure AD does not support revocation, introspection or any way to invalidate the tokens on a logout. SPAs cannot fully logout in Azure AD or Azure B2C because the tokens cannot be invalidated. You should not be sharing the tokens in the untrusted zone due to this as this is hard to secure and you need to evaluate the risk of losing the tokens for your system. Using cookies with same site protection and keeping the tokens in the trusted backend reduces these security risks. Here is a quick start for dotnet Blazor BFF using Azure AD: Blazor.BFF.AzureAD.Template.
The ConfigureServices method is used to setup the services. You can do this in the program file as well. The AddAntiforgery method is used because cookies are used to access the API. Same site is also used to protect the cookies which should only work on the same domain and no sub domains or any other domain. The AddMicrosoftIdentityWebAppAuthentication method is used with a downstream API used for Microsoft Graph. Razor pages are used as the Blazor WASM is hosted in a Razor page and dynamic server data can be used to protect the application or also be used to add meta tags.
The Configure method adds the middleware as required. The UseSecurityHeaders method adds all the required security headers which are possible for Blazor. The Razor page _Host is used as the fallback, not a static page.
The security headers are implemented using the NetEscapades.AspNetCore.SecurityHeaders Nuget package. This adds everything which is possible for a production deployment. If you want to use hot reload you need to disable some of these policies. You must ensure that the CSS, script code is not implemented in a bad way, otherwise you leave you application open for attacks. A good dev environment should be as close as possible to the production deployment. I don’t use hot reload due to this. Due to Blazorise, the style policy allows inline style in the CSP protection.
public static HeaderPolicyCollection GetHeaderPolicyCollection(bool isDev, string idpHost)
{
var policy = new HeaderPolicyCollection()
.AddFrameOptionsDeny()
.AddXssProtectionBlock()
.AddContentTypeOptionsNoSniff()
.AddReferrerPolicyStrictOriginWhenCrossOrigin()
.AddCrossOriginOpenerPolicy(builder =>
{
builder.SameOrigin();
})
.AddCrossOriginResourcePolicy(builder =>
{
builder.SameOrigin();
})
.AddCrossOriginEmbedderPolicy(builder => // remove for dev if using hot reload
{
builder.RequireCorp();
})
.AddContentSecurityPolicy(builder =>
{
builder.AddObjectSrc().None();
builder.AddBlockAllMixedContent();
builder.AddImgSrc().Self().From("data:");
builder.AddFormAction().Self().From(idpHost);
builder.AddFontSrc().Self();
builder.AddStyleSrc().Self().UnsafeInline();
builder.AddBaseUri().Self();
builder.AddFrameAncestors().None();
// due to Blazor
builder.AddScriptSrc()
.Self()
.WithHash256("v8v3RKRPmN4odZ1CWM5gw80QKPCCWMcpNeOmimNL2AA=")
.UnsafeEval();
// due to Blazor hot reload requires you to disable script and style CSP protection
// if using hot reload, DO NOT deploy an with an insecure CSP
})
.RemoveServerHeader()
.AddPermissionsPolicy(builder =>
{
builder.AddAccelerometer().None();
builder.AddAutoplay().None();
builder.AddCamera().None();
builder.AddEncryptedMedia().None();
builder.AddFullscreen().All();
builder.AddGeolocation().None();
builder.AddGyroscope().None();
builder.AddMagnetometer().None();
builder.AddMicrophone().None();
builder.AddMidi().None();
builder.AddPayment().None();
builder.AddPictureInPicture().None();
builder.AddSyncXHR().None();
builder.AddUsb().None();
});
if (!isDev)
{
// maxage = one year in seconds
policy.AddStrictTransportSecurityMaxAgeIncludeSubDomains(maxAgeInSeconds: 60 * 60 * 24 * 365);
}
return policy;
}
Microsoft Graph client service
The GraphServiceClient service can be used directly from the IoC because of how we setup the Microsoft.Identity.Web configuration in the Startup class to use Microsoft Graph and Azure AD. A persistent cache is required for this to work correctly. The GetUserIdAsync method is used to get the Id of the user behind the email address. An equals filter is used. This method is used in most of the services.
The GetGraphApiUser method uses an email to get the profile data of that user. This can be any email from your tenant. The users of the Microsoft Graph client is used.
public async Task<User> GetGraphApiUser(string email)
{
var id= await GetUserIdAsync(email);
if (string.IsNullOrEmpty(upn))
return null;
return await _graphServiceClient.Users[id]
.Request()
.GetAsync();
}
The GetUserMailboxSettings method is used to get the MailboxSettings for the given email. The Id for the user is requested, then the MailboxSettings settings are returned for the user. This only works, if the MailboxSettings.Read permission is granted to the Azure App registration.
public async Task<MailboxSettings> GetUserMailboxSettings(string email)
{
var id= await GetUserIdAsync(email);
if (string.IsNullOrEmpty(upn))
return null;
var user = await _graphServiceClient.Users[id]
.Request()
.Select("MailboxSettings")
.GetAsync();
return user.MailboxSettings;
}
The GetCalanderForUser returns the calendar for the given email. This returns a flat list of FilteredEvent items. Microsoft Graph returns a IUserCalendarViewCollectionPage which is a bit complicated for using if only requesting small amounts of data. This works well for large results which needs to be paged, streamed or whatever. The CalendarView is used with a ‘to’ and a ‘from’ datetime filter to request the calendar events.
public async Task<List<FilteredEvent>> GetCalanderForUser(string email, string from, string to)
{
var userCalendarViewCollectionPages = await GetCalanderForUserUsingGraph(email, from, to);
var allEvents = new List<FilteredEvent>();
while (userCalendarViewCollectionPages != null && userCalendarViewCollectionPages.Count > 0)
{
foreach (var calenderEvent in userCalendarViewCollectionPages)
{
var filteredEvent = new FilteredEvent
{
ShowAs = calenderEvent.ShowAs,
Sensitivity = calenderEvent.Sensitivity,
Start = calenderEvent.Start,
End = calenderEvent.End,
Subject = calenderEvent.Subject,
IsAllDay = calenderEvent.IsAllDay,
Location = calenderEvent.Location
};
allEvents.Add(filteredEvent);
}
if (userCalendarViewCollectionPages.NextPageRequest == null)
break;
}
return allEvents;
}
private async Task<IUserCalendarViewCollectionPage> GetCalanderForUserUsingGraph(string email, string from, string to)
{
var id= await GetUserIdAsync(email);
if (string.IsNullOrEmpty(id))
return null;
var queryOptions = new List<QueryOption>()
{
new QueryOption("startDateTime", from),
new QueryOption("endDateTime", to)
};
var calendarView = await _graphServiceClient.Users[id].CalendarView
.Request(queryOptions)
.Select("start,end,subject,location,sensitivity, showAs, isAllDay")
.GetAsync();
return calendarView;
}
The GetPresenceforEmail returns a teams presence list for the given email. This only works if the Presence.Read.All delegated permission is granted to the Azure App registration. Again Microsoft Graph returns a paged result which is not required in our use case. We only want this for a single email.
public async Task<List<Presence>> GetPresenceforEmail(string email)
{
var cloudCommunicationPages = await GetPresenceAsync(email);
var allPresenceItems = new List<Presence>();
while (cloudCommunicationPages != null && cloudCommunicationPages.Count > 0)
{
foreach (var presence in cloudCommunicationPages)
{
allPresenceItems.Add(presence);
}
if (cloudCommunicationPages.NextPageRequest == null)
break;
}
return allPresenceItems;
}
private async Task<ICloudCommunicationsGetPresencesByUserIdCollectionPage> GetPresenceAsync(string email)
{
var id = await GetUserIdAsync(email);
var ids = new List<string>()
{
id
};
return await _graphServiceClient.Communications
.GetPresencesByUserId(ids)
.Request()
.PostAsync();
}
Blazor Server API
The Blazor server host application implements an API which requires cookies and a correct anti-forgery token to access the protected resource. This can only be accessed from the same domain. The used cookie has also same site protection and should only work for the exact same domain. All the Blazor WASM API calls use this API in for the Microsoft Graph data displays. The WASM application does not authenticate directly or use the Microsoft Graph service directly. The Microsoft Graph client is not exposed to the untrusted client browser.
[ValidateAntiForgeryToken]
[Authorize(AuthenticationSchemes = CookieAuthenticationDefaults.AuthenticationScheme)]
[AuthorizeForScopes(Scopes = new string[] { "User.ReadBasic.All user.read" })]
[ApiController]
[Route("api/[controller]")]
public class GraphApiCallsController : ControllerBase
{
private GraphApiClientService _graphApiClientService;
public GraphApiCallsController(GraphApiClientService graphApiClientService)
{
_graphApiClientService = graphApiClientService;
}
[HttpGet("UserProfile")]
public async Task<IEnumerable<string>> UserProfile()
{
var userData = await _graphApiClientService.GetGraphApiUser(User.Identity.Name);
return new List<string> { $"DisplayName: {userData.DisplayName}",
$"GivenName: {userData.GivenName}", $"Preferred Language: {userData.PreferredLanguage}" };
}
[HttpPost("MailboxSettings")]
public async Task<IActionResult> MailboxSettings([FromBody] string email)
{
if (string.IsNullOrEmpty(email))
return BadRequest("No email");
try
{
var mailbox = await _graphApiClientService.GetUserMailboxSettings(email);
if(mailbox == null)
{
return NotFound($"mailbox settings for {email} not found");
}
var result = new List<MailboxSettingsData> {
new MailboxSettingsData { Name = "User Email", Data = email },
new MailboxSettingsData { Name = "AutomaticRepliesSetting", Data = mailbox.AutomaticRepliesSetting.Status.ToString() },
new MailboxSettingsData { Name = "TimeZone", Data = mailbox.TimeZone },
new MailboxSettingsData { Name = "Language", Data = mailbox.Language.DisplayName }
};
return Ok(result);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpPost("TeamsPresence")]
public async Task<IActionResult> PresencePost([FromBody] string email)
{
if (string.IsNullOrEmpty(email))
return BadRequest("No email");
try
{
var userPresence = await _graphApiClientService.GetPresenceforEmail(email);
if (userPresence.Count == 0)
{
return NotFound(email);
}
var result = new List<PresenceData> {
new PresenceData { Name = "User Email", Data = email },
new PresenceData { Name = "Availability", Data = userPresence[0].Availability }
};
return Ok(result);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpPost("UserCalendar")]
public async Task<IEnumerable<FilteredEventDto>> UserCalendar(UserCalendarDataModel userCalendarDataModel)
{
var userCalendar = await _graphApiClientService.GetCalanderForUser(
userCalendarDataModel.Email,
userCalendarDataModel.From.Value.ToString("yyyy-MM-ddTHH:mm:ss.sssZ"),
userCalendarDataModel.To.Value.ToString("yyyy-MM-ddTHH:mm:ss.sssZ"));
return userCalendar.Select(l => new FilteredEventDto
{
IsAllDay = l.IsAllDay.GetValueOrDefault(),
Sensitivity = l.Sensitivity.ToString(),
Start = l.Start?.DateTime,
End = l.End?.DateTime,
ShowAs = l.ShowAs.Value.ToString(),
Subject=l.Subject
});
}
Blazor Calendar client
The Blazor WASM client does not implement any security and does not require a Microsoft.Identity.Web client. I like Blazorize and the Nuget packages are added to the UI so that these components can be used. These are nice components but use inline css.
The Blazor WASM client is hosted in a Razor page. This makes it possible to add dynamic data. The anti-forgery token is added here as well as the security headers and the dynamic meta data if required.
The user calendar WASM view uses an input text field to enter any tenant email. This posts a request to the server API add returns the data which is displayed in the Blazorise Data Grid.
Running the application, a user can sign-in and request calendar data, mailbox settings or teams presence of any user in the tenant.
Using Graph together with Microsoft.Identity.Web works really well and can be implemented with little effort. By using the BFF and hosting the WASM in an ASP.NET Core application, less sensitive data needs to be exposed and it is possible to sign out without tokens possibly still existing in the untrusted zone after the logout. Blazor and Blazorise could be improved to support a better CSP and better security headers.
This article shows how to implement authentication and secure a Blazor WASM application hosted in ASP.NET Core using the backend for frontend (BFF) security architecture to authenticate. All security is implemented in the backend and the Blazor WASM is a view of the ASP.NET Core application, no security is implemented in the public client. The application is a trusted client and a secret is used to authenticate the application as well as the identity. The Blazor WASM UI can only use the hosted APIs on the same domain.
The Blazor WASM and the ASP.NET Core host application is implemented as a single application and deployed as one. The server part implements the authentication using OpenID Connect. OpenIddict is used to implement the OpenID Connect server application. The code flow with PKCE and a user secret is used for authentication.
Open ID Connect Server setup
The OpenID Connect server is implemented using OpenIddict. The is standard implementation as like the documentation. The worker class implements the IHostService interface and is used to add the code flow client used by the Blazor ASP.NET Core application. PKCE is added as well as a client secret.
The client application was created using the Blazor.BFF.OpenIDConnect.Template Nuget template package. The configuration is read from the app settings using the OpenIDConnectSettings section. You could add more configurations if required. This is otherwise a standard OpenID Connect client and will work with any OIDC compatible server. PKCE is required and also a secret to validate the application. The AddAntiforgery method is used so that API calls can be forced to validate anti-forgery token to protect against CSRF as well as the same site cookie protection.
The NetEscapades.AspNetCore.SecurityHeaders Nuget package is used to add security headers to the application to protect the session. The configuration is setup for Blazor.
public static HeaderPolicyCollection GetHeaderPolicyCollection(bool isDev, string idpHost)
{
var policy = new HeaderPolicyCollection()
.AddFrameOptionsDeny()
.AddXssProtectionBlock()
.AddContentTypeOptionsNoSniff()
.AddReferrerPolicyStrictOriginWhenCrossOrigin()
.AddCrossOriginOpenerPolicy(builder =>
{
builder.SameOrigin();
})
.AddCrossOriginResourcePolicy(builder =>
{
builder.SameOrigin();
})
.AddCrossOriginEmbedderPolicy(builder => // remove for dev if using hot reload
{
builder.RequireCorp();
})
.AddContentSecurityPolicy(builder =>
{
builder.AddObjectSrc().None();
builder.AddBlockAllMixedContent();
builder.AddImgSrc().Self().From("data:");
builder.AddFormAction().Self().From(idpHost);
builder.AddFontSrc().Self();
builder.AddStyleSrc().Self();
builder.AddBaseUri().Self();
builder.AddFrameAncestors().None();
// due to Blazor
builder.AddScriptSrc()
.Self()
.WithHash256("v8v3RKRPmN4odZ1CWM5gw80QKPCCWMcpNeOmimNL2AA=")
.UnsafeEval();
// due to Blazor hot reload requires you to disable script and style CSP protection
// if using hot reload, DO NOT deploy an with an insecure CSP
})
.RemoveServerHeader()
.AddPermissionsPolicy(builder =>
{
builder.AddAccelerometer().None();
builder.AddAutoplay().None();
builder.AddCamera().None();
builder.AddEncryptedMedia().None();
builder.AddFullscreen().All();
builder.AddGeolocation().None();
builder.AddGyroscope().None();
builder.AddMagnetometer().None();
builder.AddMicrophone().None();
builder.AddMidi().None();
builder.AddPayment().None();
builder.AddPictureInPicture().None();
builder.AddSyncXHR().None();
builder.AddUsb().None();
});
if (!isDev)
{
// maxage = one year in seconds
policy.AddStrictTransportSecurityMaxAgeIncludeSubDomains(maxAgeInSeconds: 60 * 60 * 24 * 365);
}
return policy;
}
The APIs used by the Blazor UI are protected by the ValidateAntiForgeryToken and the Authorize attribute. You could add authorization as well if required. Cookies are used for this API with same site protection.
[ValidateAntiForgeryToken]
[Authorize(AuthenticationSchemes = CookieAuthenticationDefaults.AuthenticationScheme)]
[ApiController]
[Route("api/[controller]")]
public class DirectApiController : ControllerBase
{
[HttpGet]
public IEnumerable<string> Get()
{
return new List<string> { "some data", "more data", "loads of data" };
}
}
When the application is started, the user can sign-in and authenticate using OpenIddict.
The setup keeps all the security implementation in the trusted backend. This setup can work against any OpenID Connect conform server. By having a trusted application, it is now possible to implement access to downstream APIs in a number of ways and possible to add further protections as required. The downstream API does not need to be public either. You should only use a downstream API if required. If a software architecture forces you to use APIs from separate domains, then a YARP reverse proxy can be used to access to API, or a service to service API call, ie trusted client with a trusted server, or an on behalf flow (OBO) flow can be used.
The article shows how to implement a progressive web application (PWA) using Blazor which is secured using the backend for frontend architecture and Azure B2C as the identity provider.
The application is setup to implement all security in the trusted backend and reduce the security risks of the overall software. We use Azure B2C as an identity provider. When implementing and using BFF security architecture, cookies are used to secure the Blazor WASM UI and its backend. Microsoft.Identity.Web is used to implement the authentication as recommended by Microsoft for server rendered applications. Anti-forgery tokens as well as all the other cookie protections can be used to reduce the risk of CSRF attacks. This requires that the WASM application is hosted in an ASP.NET Core razor page and the dynamic data can be added. With PWA applications, this is not possible. To work around this, CORS preflight and custom headers can be used to protect against this as well as same site. The anti-forgery cookies need to be removed to support PWAs. Using CORS preflight has some disadvantages compared to anti-forgery cookies but works good.
Setup Blazor BFF with Azure B2C for PWA
The application is setup using the Blazor.BFF.AzureB2C.Template Nuget package. This uses anti-forgery cookies. All of the anti-forgery protection can be completely removed. The Azure App registrations and the Azure B2C user flows need to be setup and the application should work (without PWA support).
To setup the PWA support, you need to add an index.html file to the wwwroot of the Blazor client and a service worker JS script to implement the PWA. The index.html file adds what is required and the serviceWorkerRegistration.js script is linked.
The serviceWorker.published.js script is pretty standard except that the OpenID Connect redirects and signout URLs need to be excluded from the PWA and always rendered from the trusted backend. The registration script references the service worker so that the inline Javascript is removed from the html because we do not allow unsafe inline scripts anywhere in an application if possible.
The service worker excludes all the required authentication URLs and any other required server URLs. The published script registers the PWA.
Note: if you would like to test the PWA locally without deploying the application, you can reference the published script directly and it will run locally. You need to be carefully testing as the script and the cache needs to be emptied before testing each time.
// Caution! Be sure you understand the caveats before publishing an application with
// offline support. See https://aka.ms/blazor-offline-considerations
self.importScripts('./service-worker-assets.js');
self.addEventListener('install', event => event.waitUntil(onInstall(event)));
self.addEventListener('activate', event => event.waitUntil(onActivate(event)));
self.addEventListener('fetch', event => event.respondWith(onFetch(event)));
const cacheNamePrefix = 'offline-cache-';
const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`;
const offlineAssetsInclude = [/\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/];
const offlineAssetsExclude = [/^service-worker\.js$/];
async function onInstall(event) {
console.info('Service worker: Install');
// Fetch and cache all matching items from the assets manifest
const assetsRequests = self.assetsManifest.assets
.filter(asset => offlineAssetsInclude.some(pattern => pattern.test(asset.url)))
.filter(asset => !offlineAssetsExclude.some(pattern => pattern.test(asset.url)))
.map(asset => new Request(asset.url, { integrity: asset.hash, cache: 'no-cache' }));
await caches.open(cacheName).then(cache => cache.addAll(assetsRequests));
}
async function onActivate(event) {
console.info('Service worker: Activate');
// Delete unused caches
const cacheKeys = await caches.keys();
await Promise.all(cacheKeys
.filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName)
.map(key => caches.delete(key)));
}
async function onFetch(event) {
let cachedResponse = null;
if (event.request.method === 'GET') {
// For all navigation requests, try to serve index.html from cache
// If you need some URLs to be server-rendered, edit the following check to exclude those URLs
const shouldServeIndexHtml = event.request.mode === 'navigate'
&& !event.request.url.includes('/signin-oidc')
&& !event.request.url.includes('/signout-callback-oidc')
&& !event.request.url.includes('/api/Account/Login')
&& !event.request.url.includes('/api/Account/Logout')
&& !event.request.url.includes('/HostAuthentication/');
const request = shouldServeIndexHtml ? 'index.html' : event.request;
const cache = await caches.open(cacheName);
cachedResponse = await cache.match(request);
}
return cachedResponse || fetch(event.request, { credentials: 'include' });
}
The ServiceWorkerAssetsManifest definition needs to be added to the client project.
Now the PWA should work. The next step is to add the extra CSRF protection.
Setup CSRF protection using CORS preflight
CORS preflight can be used to protect against CSRF as well as same site. All API calls should include a custom HTTP header and this needs to be controlled on the APIs that the header exists.
The can be implemented in the Blazor WASM client by using a CSRF middleware protection.
public class CsrfProtectionCorsPreflightAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext context)
{
var header = context.HttpContext
.Request
.Headers
.Any(p => p.Key.ToLower() == "x-force-cors-preflight");
if (!header)
{
// "X-FORCE-CORS-PREFLIGHT header is missing"
context.Result = new UnauthorizedObjectResult("X-FORCE-CORS-PREFLIGHT header is missing");
return;
}
}
}
In the Blazor client, the middleware can be added to all HttpClient instances used in the Blazor WASM.
The CSRF CORS preflight header can be validated using an ActionFilter in the ASP.NET Core backend application. This is not the only way of doing this. The CsrfProtectionCorsPreflightAttribute implements the ActionFilterAttribute so only the OnActionExecuting needs to be implemented. The custom header is validated and if it fails, an unauthorized result is returned. It does not matter if you give the reason why, unless you want to obfuscate this a bit.
public class CsrfProtectionCorsPreflightAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext context)
{
var header = context.HttpContext
.Request
.Headers
.Any(p => p.Key.ToLower() == "x-force-cors-preflight");
if (!header)
{
// "X-FORCE-CORS-PREFLIGHT header is missing"
context.Result = new UnauthorizedObjectResult("X-FORCE-CORS-PREFLIGHT header is missing");
return;
}
}
}
The CSRF can then be applied anywhere this is required. All secured routes where cookies are used should enforce this.
[CsrfProtectionCorsPreflight]
[Authorize(AuthenticationSchemes = CookieAuthenticationDefaults.AuthenticationScheme)]
[ApiController]
[Route("api/[controller]")]
public class DirectApiController : ControllerBase
{
[HttpGet]
public IEnumerable<string> Get()
{
return new List<string> { "some data", "more data", "loads of data" };
}
}
Now the PWA works using the server rendered application and protected using BFF with all security in the trusted backend.
Problems with this solution and Blazor
The custom header cannot be applied and added when sending direct links, redirects or forms which don’t use Javascript. Anywhere a form is implemented and requires the CORS preflight protection, a HttpClient which adds the header needs to be used.
This is a problem with the Azure B2C signin and signout. The signin redirects the whole application, but this is not so much a problem because when signing in, the identity has no cookie with sensitive data, or should have none. The signout only works correctly with Azure B2C with a form request from the whole application and not HttpClient API call using Javascript. The CORS preflight header cannot be applied to an Azure B2C identity provider signout request, if you require the session to be ended on Azure B2C. If you only require a local logout, then the HttpClient can be used.
Note: Same site protection also exists for modern browsers, so this double CSRF fallback is not really critical, if the same site is implemented correctly and using a browser which enforces this.