I really like the answer from @AlexLobakov but I wanted an updated answer for .NET 6 and also something that was testable but still implemented the caching features. I also wanted the roles to be sent to my front end, be compatible with any SPA like React and use standard Azure AD B2C User flows for Role-based access control (RBAC) in my application.
I also missed a start to finish guide, so many variables that can go wrong and you end up with an application not working.
Start with creating a new ASP.NET Core Web API in Visual Studio 2022 with the following settings:

You should get a dialogue like this after creation:

If you don't see this then right click on the project in Visual Studio and click on Overview and then Connected services.

Create a new App registration in your Azure AD B2C or use an existing. I registered a new one for this demo purpose.

After creating the App registration Visual Studio got stuck on Dependency configuration progress so the rest will be configured manually:

Log on to https://portal.azure.com/, Switch directory to your AD B2C, select your new App registration and then click on Authentication. Then click on Add a platform and select Web.
Add a Redirect URI and Front-channel logout URL for localhost.
Example:
https://localhost:7166/signin-oidc
https://localhost:7166/logout

If you choose Single-page application instead it will look nearly the same. However you then need to add a code_challenge as described below. A full example for this will not be shown.
Is Active Directory not supporting Authorization Code Flow with PKCE?

Authentication should look something like this:

Click on Certificates & secrets and create a new Client secret.
Click on Expose an API and then edit Application ID URI.
Default value should look something like this api://11111111-1111-1111-1111-111111111111. Edit it to be https://youradb2c.onmicrosoft.com/11111111-1111-1111-1111-111111111111. There should be a scope named access_as_user. Create if it is not there.
Now click on API permissions:
Four Microsoft Graph permissions are needed.
Two Application:
GroupMember.Read.All
User.Read.All
Two Delegated:
offline_access
openid
You also need your access_as_user permission from My APIs. When this is done click on Grant admin consent for .... Should look like this:

If you don't have a User Flow already then create either a Sign up and sign in or a Sign in and select Recommended. My user flow is default B2C_1_signin.

Verify that your AD B2C user is a member of the group you want to authenticate against:

Now you can go back to your application and verify that you can get a code to login. Use this sample and it should redirect with a code:
https://<tenant-name>.b2clogin.com/tfp/<tenant-name>.onmicrosoft.com/<user-flow-name>/oauth2/v2.0/authorize?
client_id=<application-ID>
&nonce=anyRandomValue
&redirect_uri=https://localhost:7166/signin-oidc
&scope=https://<tenant-name>.onmicrosoft.com/11111111-1111-1111-1111-111111111111/access_as_user
&response_type=code
If it works you should be redirected to something like this after login:
https://localhost:7166/signin-oidc?code=
If you get an error that says:
AADB2C99059: The supplied request must present a code_challenge
Then you have probably selected platform Single-page application and needs to add a code_challenge to the request like: &code_challenge=123. This is not enough because you also need to validate the challenge later otherwise you will get the error below when running my code.
AADB2C90183: The supplied code_verifier is invalid
Now open your application and appsettings.json. Default should look something like this:
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"Domain": "qualified.domain.name",
"TenantId": "22222222-2222-2222-2222-222222222222",
"ClientId": "11111111-1111-1111-11111111111111111",
"Scopes": "access_as_user",
"CallbackPath": "/signin-oidc"
},
We need a few more values so it should look like this in the end:
"AzureAd": {
"Instance": "https://<tenant-name>.b2clogin.com/",
"Domain": "<tenant-name>.onmicrosoft.com",
"TenantId": "22222222-2222-2222-2222-222222222222",
"ClientId": "11111111-1111-1111-11111111111111111",
"SignUpSignInPolicyId": "B2C_1_signin",
"ClientSecret": "--SECRET--",
"ApiScope": "https://<tenant-name>.onmicrosoft.com/11111111-1111-1111-11111111111111111/access_as_user",
"TokenUrl": "https://<tenant-name>.b2clogin.com/<tenant-name>.onmicrosoft.com/B2C_1_signin/oauth2/v2.0/token",
"Scopes": "access_as_user",
"CallbackPath": "/signin-oidc"
},
I store ClientSecret in Secret Manager.
https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets?view=aspnetcore-6.0&tabs=windows#manage-user-secrets-with-visual-studio
Now create these new classes:
AppSettings:
namespace AzureADB2CWebAPIGroupTest
{
public class AppSettings
{
public AzureAdSettings AzureAd { get; set; } = new AzureAdSettings();
}
public class AzureAdSettings
{
public string Instance { get; set; }
public string Domain { get; set; }
public string TenantId { get; set; }
public string ClientId { get; set; }
public string IssuerSigningKey { get; set; }
public string ValidIssuer { get; set; }
public string ClientSecret { get; set; }
public string ApiScope { get; set; }
public string TokenUrl { get; set; }
}
}
Adb2cTokenResponse:
namespace AzureADB2CWebAPIGroupTest
{
public class Adb2cTokenResponse
{
public string access_token { get; set; }
public string id_token { get; set; }
public string token_type { get; set; }
public int not_before { get; set; }
public int expires_in { get; set; }
public int ext_expires_in { get; set; }
public int expires_on { get; set; }
public string resource { get; set; }
public int id_token_expires_in { get; set; }
public string profile_info { get; set; }
public string scope { get; set; }
public string refresh_token { get; set; }
public int refresh_token_expires_in { get; set; }
}
}
CacheKeys:
namespace AzureADB2CWebAPIGroupTest
{
public static class CacheKeys
{
public const string GraphApiAccessToken = "_GraphApiAccessToken";
}
}
GraphApiService:
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Graph;
using System.Text.Json;
namespace AzureADB2CWebAPIGroupTest
{
public class GraphApiService
{
private readonly IHttpClientFactory _clientFactory;
private readonly IMemoryCache _memoryCache;
private readonly AppSettings _settings;
private readonly string _accessToken;
public GraphApiService(IHttpClientFactory clientFactory, IMemoryCache memoryCache, AppSettings settings)
{
_clientFactory = clientFactory;
_memoryCache = memoryCache;
_settings = settings;
string graphApiAccessTokenCacheEntry;
// Look for cache key.
if (!_memoryCache.TryGetValue(CacheKeys.GraphApiAccessToken, out graphApiAccessTokenCacheEntry))
{
// Key not in cache, so get data.
var adb2cTokenResponse = GetAccessTokenAsync().GetAwaiter().GetResult();
graphApiAccessTokenCacheEntry = adb2cTokenResponse.access_token;
// Set cache options.
var cacheEntryOptions = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromSeconds(adb2cTokenResponse.expires_in));
// Save data in cache.
_memoryCache.Set(CacheKeys.GraphApiAccessToken, graphApiAccessTokenCacheEntry, cacheEntryOptions);
}
_accessToken = graphApiAccessTokenCacheEntry;
}
public async Task<List<string>> GetUserGroupsAsync(string oid)
{
var authProvider = new AuthenticationProvider(_accessToken);
GraphServiceClient graphClient = new GraphServiceClient(authProvider, new HttpClientHttpProvider(_clientFactory.CreateClient()));
//Requires GroupMember.Read.All and User.Read.All to get everything we want
var groups = await graphClient.Users[oid].MemberOf.Request().GetAsync();
if (groups == null)
{
return null;
}
var graphGroup = groups.Cast<Microsoft.Graph.Group>().ToList();
return graphGroup.Select(x => x.DisplayName).ToList();
}
private async Task<Adb2cTokenResponse> GetAccessTokenAsync()
{
var client = _clientFactory.CreateClient();
var kvpList = new List<KeyValuePair<string, string>>();
kvpList.Add(new KeyValuePair<string, string>("grant_type", "client_credentials"));
kvpList.Add(new KeyValuePair<string, string>("client_id", _settings.AzureAd.ClientId));
kvpList.Add(new KeyValuePair<string, string>("scope", "https://graph.microsoft.com/.default"));
kvpList.Add(new KeyValuePair<string, string>("client_secret", _settings.AzureAd.ClientSecret));
#pragma warning disable SecurityIntelliSenseCS // MS Security rules violation
var req = new HttpRequestMessage(HttpMethod.Post, $"https://login.microsoftonline.com/{_settings.AzureAd.Domain}/oauth2/v2.0/token")
{ Content = new FormUrlEncodedContent(kvpList) };
#pragma warning restore SecurityIntelliSenseCS // MS Security rules violation
using var httpResponse = await client.SendAsync(req);
var response = await httpResponse.Content.ReadAsStringAsync();
httpResponse.EnsureSuccessStatusCode();
var adb2cTokenResponse = JsonSerializer.Deserialize<Adb2cTokenResponse>(response);
return adb2cTokenResponse;
}
}
public class AuthenticationProvider : IAuthenticationProvider
{
private readonly string _accessToken;
public AuthenticationProvider(string accessToken)
{
_accessToken = accessToken;
}
public Task AuthenticateRequestAsync(HttpRequestMessage request)
{
request.Headers.Add("Authorization", $"Bearer {_accessToken}");
return Task.CompletedTask;
}
}
public class HttpClientHttpProvider : IHttpProvider
{
private readonly HttpClient http;
public HttpClientHttpProvider(HttpClient http)
{
this.http = http;
}
public ISerializer Serializer { get; } = new Serializer();
public TimeSpan OverallTimeout { get; set; } = TimeSpan.FromSeconds(300);
public void Dispose()
{
}
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request)
{
return http.SendAsync(request);
}
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
HttpCompletionOption completionOption,
CancellationToken cancellationToken)
{
return http.SendAsync(request, completionOption, cancellationToken);
}
}
}
At the moment only accessToken for GraphServiceClient is stored in memorycache but if the application requires better performance a users groups could also be cached.
Add a new class:
Adb2cUser:
namespace AzureADB2CWebAPIGroupTest
{
public class Adb2cUser
{
public Guid Id { get; set; }
public string GivenName { get; set; }
public string FamilyName { get; set; }
public string Email { get; set; }
public List<string> Roles { get; set; }
public Adb2cTokenResponse Adb2cTokenResponse { get; set; }
}
}
and struct:
namespace AzureADB2CWebAPIGroupTest
{
public struct ADB2CJwtRegisteredClaimNames
{
public const string Emails = "emails";
public const string Name = "name";
}
}
And now add a new API Controller
LoginController:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.IdentityModel.Tokens.Jwt;
using System.Text.Json;
namespace AzureADB2CWebAPIGroupTest.Controllers
{
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class LoginController : ControllerBase
{
private readonly ILogger<LoginController> _logger;
private readonly IHttpClientFactory _clientFactory;
private readonly AppSettings _settings;
private readonly GraphApiService _graphApiService;
public LoginController(ILogger<LoginController> logger, IHttpClientFactory clientFactory, AppSettings settings, GraphApiService graphApiService)
{
_logger = logger;
_clientFactory = clientFactory;
_settings = settings;
_graphApiService=graphApiService;
}
[HttpPost]
[AllowAnonymous]
public async Task<ActionResult<Adb2cUser>> Post([FromBody] string code)
{
var redirectUri = "";
if (HttpContext != null)
{
redirectUri = HttpContext.Request.Scheme + "://" + HttpContext.Request.Host + "/signin-oidc";
}
var kvpList = new List<KeyValuePair<string, string>>();
kvpList.Add(new KeyValuePair<string, string>("grant_type", "authorization_code"));
kvpList.Add(new KeyValuePair<string, string>("client_id", _settings.AzureAd.ClientId));
kvpList.Add(new KeyValuePair<string, string>("scope", "openid offline_access " + _settings.AzureAd.ApiScope));
kvpList.Add(new KeyValuePair<string, string>("code", code));
kvpList.Add(new KeyValuePair<string, string>("redirect_uri", redirectUri));
kvpList.Add(new KeyValuePair<string, string>("client_secret", _settings.AzureAd.ClientSecret));
return await UserLoginAndRefresh(kvpList);
}
[HttpPost("refresh")]
[AllowAnonymous]
public async Task<ActionResult<Adb2cUser>> Refresh([FromBody] string token)
{
var redirectUri = "";
if (HttpContext != null)
{
redirectUri = HttpContext.Request.Scheme + "://" + HttpContext.Request.Host;
}
var kvpList = new List<KeyValuePair<string, string>>();
kvpList.Add(new KeyValuePair<string, string>("grant_type", "refresh_token"));
kvpList.Add(new KeyValuePair<string, string>("client_id", _settings.AzureAd.ClientId));
kvpList.Add(new KeyValuePair<string, string>("scope", "openid offline_access " + _settings.AzureAd.ApiScope));
kvpList.Add(new KeyValuePair<string, string>("refresh_token", token));
kvpList.Add(new KeyValuePair<string, string>("redirect_uri", redirectUri));
kvpList.Add(new KeyValuePair<string, string>("client_secret", _settings.AzureAd.ClientSecret));
return await UserLoginAndRefresh(kvpList);
}
private async Task<ActionResult<Adb2cUser>> UserLoginAndRefresh(List<KeyValuePair<string, string>> kvpList)
{
var user = await TokenRequest(kvpList);
if (user == null)
{
return Unauthorized();
}
//Return access token and user information
return Ok(user);
}
private async Task<Adb2cUser> TokenRequest(List<KeyValuePair<string, string>> keyValuePairs)
{
var client = _clientFactory.CreateClient();
#pragma warning disable SecurityIntelliSenseCS // MS Security rules violation
var req = new HttpRequestMessage(HttpMethod.Post, _settings.AzureAd.TokenUrl)
{ Content = new FormUrlEncodedContent(keyValuePairs) };
#pragma warning restore SecurityIntelliSenseCS // MS Security rules violation
using var httpResponse = await client.SendAsync(req);
var response = await httpResponse.Content.ReadAsStringAsync();
httpResponse.EnsureSuccessStatusCode();
var adb2cTokenResponse = JsonSerializer.Deserialize<Adb2cTokenResponse>(response);
var handler = new JwtSecurityTokenHandler();
var jwtSecurityToken = handler.ReadJwtToken(adb2cTokenResponse.access_token);
var id = jwtSecurityToken.Claims.First(claim => claim.Type == JwtRegisteredClaimNames.Sub).Value;
var groups = await _graphApiService.GetUserGroupsAsync(id);
var givenName = jwtSecurityToken.Claims.First(claim => claim.Type == JwtRegisteredClaimNames.GivenName).Value;
var familyName = jwtSecurityToken.Claims.First(claim => claim.Type == JwtRegisteredClaimNames.FamilyName).Value;
//Unless Alternate email have been added in Azure AD there will only be one email here.
//TODO Handle multiple emails
var emails = jwtSecurityToken.Claims.First(claim => claim.Type == ADB2CJwtRegisteredClaimNames.Emails).Value;
var user = new Adb2cUser()
{
Id = Guid.Parse(id),
GivenName = givenName,
FamilyName = familyName,
Email = emails,
Roles = groups,
Adb2cTokenResponse = adb2cTokenResponse
};
return user;
}
}
}
Now it is time to edit Program.cs. Should look something like this for the new minimal hosting model in ASP.NET Core 6.0:
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));
Notice that ASP.NET Core 6.0 are using JwtBearerDefaults.AuthenticationScheme and not AzureADB2CDefaults.AuthenticationScheme or AzureADB2CDefaults.OpenIdScheme.
Edit so Program.cs looks like this:
using AzureADB2CWebAPIGroupTest;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Identity.Web;
using System.Security.Claims;
var builder = WebApplication.CreateBuilder(args);
//Used for debugging
//IdentityModelEventSource.ShowPII = true;
var settings = new AppSettings();
builder.Configuration.Bind(settings);
builder.Services.AddSingleton(settings);
var services = new ServiceCollection();
services.AddMemoryCache();
services.AddHttpClient();
var serviceProvider = services.BuildServiceProvider();
var memoryCache = serviceProvider.GetService<IMemoryCache>();
var httpClientFactory = serviceProvider.GetService<IHttpClientFactory>();
var graphApiService = new GraphApiService(httpClientFactory, memoryCache, settings);
// Add services to the container.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(options => {
builder.Configuration.Bind("AzureAd", options);
options.TokenValidationParameters.NameClaimType = "name";
options.TokenValidationParameters.ValidateIssuerSigningKey = true;
options.TokenValidationParameters.ValidateLifetime = true;
options.TokenValidationParameters.ValidateIssuer = true;
options.TokenValidationParameters.ValidateLifetime = true;
options.TokenValidationParameters.ValidateTokenReplay = true;
options.Audience = settings.AzureAd.ClientId;
options.Events = new JwtBearerEvents()
{
OnTokenValidated = async ctx =>
{
//Runs on every request, cache a users groups if needed
var oidClaim = ((System.IdentityModel.Tokens.Jwt.JwtSecurityToken)ctx.SecurityToken).Claims.FirstOrDefault(c => c.Type == "oid");
if (!string.IsNullOrWhiteSpace(oidClaim?.Value))
{
var groups = await graphApiService.GetUserGroupsAsync(oidClaim.Value);
foreach (var group in groups)
{
((ClaimsIdentity)ctx.Principal.Identity).AddClaim(new Claim(ClaimTypes.Role.ToString(), group));
}
}
}
};
},
options => {
builder.Configuration.Bind("AzureAd", options);
});
builder.Services.AddTransient<GraphApiService>();
builder.Services.AddHttpClient();
builder.Services.AddMemoryCache();
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
Now you can run your application and use the code from earlier in a request like this:
POST /api/login/ HTTP/1.1
Host: localhost:7166
Content-Type: application/json
"code"
You will then receieve a response like this with an access_token:
{
"id": "31111111-1111-1111-1111-111111111111",
"givenName": "Oscar",
"familyName": "Andersson",
"email": "oscar.andersson@example.com",
"roles": [
"Administrator",
],
"adb2cTokenResponse": {
}
}
Adding [Authorize(Roles = "Administrator")] to WeatherForecastController.cs we can now verify that only a user with the correct role is allowed to access this resource using the access_token we got earlier:

If we change to [Authorize(Roles = "Administrator2")] we get a HTTP 403 with the same user:

LoginController can handle refresh tokens as well.
With NuGets Microsoft.NET.Test.Sdk, xunit, xunit.runner.visualstudio and Moq we can also test LoginController and in turn also GraphApiService used for ClaimsIdentity in Program.cs. Unfortunately due body being limited to 30000 charcters the entire test can not be shown.
It basically looks like this:
LoginControllerTest:
using AzureADB2CWebAPIGroupTest.Controllers;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Moq;
using Moq.Protected;
using System.Net;
using Xunit;
namespace AzureADB2CWebAPIGroupTest
{
public class LoginControllerTest
{
[Theory]
[MemberData(nameof(PostData))]
public async Task Post(string code, string response, string expectedEmail, string expectedFamilyName, string expectedGivenName)
{
var controller = GetLoginController(response);
var result = await controller.Post(code);
var actionResult = Assert.IsType<ActionResult<Adb2cUser>>(result);
var okResult = Assert.IsType<OkObjectResult>(result.Result);
var returnValue = Assert.IsType<Adb2cUser>(okResult.Value);
Assert.Equal(returnValue.Email, expectedEmail);
Assert.Equal(returnValue.Roles[1], GraphApiServiceMock.DummyGroup2Name);
}
[Theory]
[MemberData(nameof(RefreshData))]
public async Task Refresh(string code, string response, string expectedEmail, string expectedFamilyName, string expectedGivenName)
{
var controller = GetLoginController(response);
var result = await controller.Refresh(code);
var actionResult = Assert.IsType<ActionResult<Adb2cUser>>(result);
var okResult = Assert.IsType<OkObjectResult>(result.Result);
var returnValue = Assert.IsType<Adb2cUser>(okResult.Value);
Assert.Equal(returnValue.Email, expectedEmail);
Assert.Equal(returnValue.Roles[1], GraphApiServiceMock.DummyGroup2Name);
}
//PostData and RefreshData removed for space
private LoginController GetLoginController(string expectedResponse)
{
var mockFactory = new Mock<IHttpClientFactory>();
var settings = new AppSettings();
settings.AzureAd.TokenUrl = "https://example.com";
var mockMessageHandler = new Mock<HttpMessageHandler>();
GraphApiServiceMock.MockHttpRequests(mockMessageHandler);
mockMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.Is<HttpRequestMessage>(x => x.RequestUri.AbsoluteUri.Contains(settings.AzureAd.TokenUrl)), ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(expectedResponse)
});
var httpClient = new HttpClient(mockMessageHandler.Object);
mockFactory.Setup(_ => _.CreateClient(It.IsAny<string>())).Returns(httpClient);
var logger = Mock.Of<ILogger<LoginController>>();
var services = new ServiceCollection();
services.AddMemoryCache();
var serviceProvider = services.BuildServiceProvider();
var memoryCache = serviceProvider.GetService<IMemoryCache>();
var graphService = new GraphApiService(mockFactory.Object, memoryCache, settings);
var controller = new LoginController(logger, mockFactory.Object, settings, graphService);
return controller;
}
}
}
A GraphApiServiceMock.cs is also needed but it just adds more values like the example with mockMessageHandler.Protected() and static values like public static string DummyUserExternalId = "11111111-1111-1111-1111-111111111111";.

There are other ways to do this but they usually depend on Custom Policies:
https://learn.microsoft.com/en-us/answers/questions/469509/can-we-get-and-edit-azure-ad-b2c-roles-using-ad-b2.html
https://devblogs.microsoft.com/premier-developer/using-groups-in-azure-ad-b2c/
https://learn.microsoft.com/en-us/azure/active-directory-b2c/user-flow-overview