Dealing with Entra ID


Dealing with Entra ID

Wow, Entra ID has to be the most challenging identity provider to configure. I just finished integrating it into the .NET Bootcamp's Game Store application and I must admit it was not easy.

This is quite surprising since Microsoft builds Entra ID (previously known as Azure AD) and, as opposed to other providers like Keycloak, offers first-class support to interact with it from .NET applications.

Maybe I'm too used to simpler OIDC (OpenID Connect) implementations, like Keycloak or Auth0, but regardless I wanted to get this one properly integrated into the bootcamp since many folks have been asking for it and you likely don't want to deploy Keycloak to Azure when you have a robust service like Entra ID ready to go there.

Let's go over Entra ID and how I'm integrating it into the bootcamp application.

What we are trying to achieve

We want to enable OIDC for our distributed system since it's the industry standard for authentication and authorization. And, in this case, our OpenID provider is Entra ID.

I won't explain how the OIDC protocol works here since I have already covered it on my website and dedicated an entire module of my microservices program to OAuth 2.0 and OpenId Connect.

But here's a high-level view of how things should work:

  1. The end-user tries to access a page in the frontend that requires authorization
  2. The frontend talks to Entra ID to request authorization
  3. Entra ID prompts the user to sign in
  4. After a successful login, Entra ID returns an access token and an ID token to the frontend
  5. The frontend can use the ID token to identify the user (Hello Julio!)
  6. The frontend sends an HTTP request to the API gateway attaching the access token in the headers.
  7. The API gateway verifies that the request includes a valid token
  8. The API gateway forwards the request to the relevant Web API
  9. The Web API verifies and decodes the token to confirm it can authorize the request and potentially use the included claims.
  10. The Web API returns an HTTP response.

Now let's see what we need from Azure to enable this.

Entra External ID configuration

For this system, we don't want to use the standard Entra ID tenant you would use to manage identity internally across your organization. Instead, we want to use Entra External ID, which allows external identities to access your apps and resources.

You may have also heard about Azure Active Directory B2C, the predecessor to Entra External ID. That's what you would have used for this a while ago, but according to these docs, it's a legacy solution now. So we'll stick to the latest and greatest.

You first create an Entra External ID tenant (docs here), which is the entity that will hold all your applications, users, and groups, along with all the relevant permissions.

Then, you'll need to create at least 2 app registrations:

Each app registration represents either a resource to protect (your backend APIs) or a client via which your users will try to access them (your frontend).

So you want to have one for your API Gateway and one for your frontend. I have one more for testing things via the Postman client.

The fact that we only have the API Gateway there and not every individual microservice may seem a bit surprising, but it's one of the benefits of having the Gateway.

Your access tokens will be generated for the API Gateway, which will in turn verify the validity of the tokens before forwarding them to your microservices.

Your microservices will still validate the tokens and decode the included claims, but won't have to waste resources on invalid tokens since those will get rejected by the Gateway right away.

In your API Gateway registration, you should define whichever scopes are required by your microservices:

Whereas the frontend should configure delegated permissions to request those scopes from the Gateway:

We call them "delegated" permissions because end users should provide consent before the client app can make use of the backend resources on behalf of them. You can, however, grant admin consent on behalf of all users in cases where you own the client, like in our case.

Another thing you are going to need, if you want to do role-based authorization, is what Entra ID calls groups. For instance, here we have 1 group, which I called Admin, meant to hold all application administrators:

However, that will not make those admins automatically acquire a roles claim, which is what we need to check roles in the microservices. For that, you have to go into each of your Entra ID applications and first define a role:

And then you assign that role to your previously created group, in the context of each application:

This took me ages to understand but has to be done that way because if you skip the groups part you would be forced to assign users to roles on each of your applications (like, 3 apps, 3 assignments), which makes no sense.

There are a few other small details to get this right, which I'll make sure to cover in detail in the bootcamp, but for now, let's briefly peek into one of those access tokens.

The Entra ID access token

If everything is configured properly, here's how one of the access tokens requested via the frontend or via Postman should look like:

The highlighted are the claims that your microservices will receive and that they can use to:

  • Confirm the token is meant for them (aud)
  • Confirm the token was issued by the correct authority (iss)
  • Confirm the correct access was granted (scp == scopes)
  • Confirm the user belongs to the expected role (roles)
  • Identify the user on behalf of whom the request was sent (email, name, oid, etc.)

The small detail there is that the audience (aud) is the ID of the API Gateway, not any of the microservices. The microservices will trust that such is the correct audience.

Now let's see what things look like in the code.

The microservice auth code

Starting with the backend, what you want to do is use the standard JWT Bearer middleware to validate incoming tokens. Something like this:

The MapInboundClaims and TokenValidationParameters.RoleClaimType pieces are necessary to ensure the incoming access token claims land properly and the ClaimsNormalizer is a small class I created to normalize a few claims, like scp and oid into the ones I've been using with Keycloak and that are more standard, like scope and sub.

With that in place, each microservice can do this in its own appsettings.json file, which avoids hardcoding and lets us keep things a bit flexible:

Here's also how we define one of the auth policies that the microservice will check:

What's great is that the same policy will work both for Keycloak and Entra ID given how we standardized the incoming claims. And, here's how the policy can be used:

Now let's see what's required in the API Gateway.

The API Gateway auth code

Since all the API Gateway is doing is validating the incoming access tokens, its JWT bearer configuration is exactly the same as any of the microservices.

What's new on the Gateway side is a custom policy we need to implement to support both Keycloak and Entra ID:

Then you would use that policy in the relevant reverse proxy route configurations:

Finally, let's see what to do on the frontend side of things.

The frontend auth code

For the frontend I wanted to try out the Microsoft Identity Web authentication libraries as opposed to using the standard OIDC middleware, since it *should* make things easier to configure.

To start with, you'll define your OIDC configuration in appsettings.json like this:

Then you'll add this code to Program.cs:

A few key things about this configuration:

  • The frontend must know which identity provider you want to use, which we keep in that IdentityProvider setting. The backend doesn't have this problem.
  • CallbackPath and SignedOutCallbackPath must be configured in your Frontend app registration in EntraID for signin/signout to work properly.
  • The OID claim has the unique ID of the user in your tenant, while SUB claim is a unique ID for that user within a single application (so weird!).
  • AddMicrosoftIdentityWebApp() will override some configurations with its own hard-coded values, no matter how you configure it. Had to do a small trick to set the right value for NameClaimType.

Now that should get your frontend ready to sign in your users and even to display user properties and tell which sections of the frontend to show or hide.

The other important thing is how to have the frontend attach proper access tokens on outgoing calls to the API Gateway. You can do that in a DelegatingHandler:

Th ITokenAcquisition object is the hero here, which is registered by that EnableTokenAcquisitionToCallDownstreamApi() call you did before.

That's how we get an access token for the current user with the specified scopes and then we just attach it to the Auth header on the outgoing request. That .default scope, by the way, is a bit magical, since it allows you to request all scopes exposed by the resource (so strange).

I have to say that I'm not very pleased with the Microsoft.Identity.Web library, its APIs, and configurations. I hate magical stuff and I prefer to understand exactly what each line of code is doing in my app, which this library is heavily trying to hide from me.

So I might revert to the simpler OIDC middleware in the end, we'll see. But now let's take a look at the end result.

The end result

With everything configured properly, you will see this when launching the app:

Notice the Login button and the lack of menu items at the top. They are hidden because we don't know who the current user is. Clicking on Login will take you to this Entra ID page:

Notice that the sign-in happens on the identity provider page, not on your site, as it should be for a proper/secure OIDC flow. Then after signing in, you get back to the site:

Notice that, thanks to the ID token, the site can now tell who logged in and what kind of permissions this person has. We can show menu items only available to signed-in users and even a fancy avatar, just for fun.

And we can even get to the shopping cart, which requires an authenticated request to our shopping basket microservice:

So it's all working properly (uff!!!). And, remember, you only need to change 1 setting in the frontend to get the entire thing working with Keycloak instead. No need to touch the backend.

And that's it for today. I'll do a bit more polishing on the frontend to simplify the Entra ID stuff and then I'll head right into getting all this deployed to the cloud.

Until next time!

Julio


Whenever you’re ready, there are 4 ways I can help you:

  1. Building Microservices With .NET:​ A complete program designed to transform the way you build cloud-ready .NET systems at scale.
  2. Building .NET REST APIs: A carefully crafted pathway to learn how to build production-ready .NET based REST APIs, step by step.
  3. Patreon Community: Join for top discounts on all my in-depth courses and access my Discord server for community support and discussions.
  4. Promote yourself to 16,000+ subscribers by sponsoring my newsletter.

11060 236th PL NE, Redmond, WA 98053
Unsubscribe · Preferences

The .NET Saturday

Join 16,000+ subscribers for actionable .NET, C#, Azure and DevOps tips. Upgrade your skills in less than 5 mins every week.

Read more from The .NET Saturday

The Pillars of Observability After completing the Game Store application, the last week was all about scripting the first few modules of the upcoming .NET Cloud Developer Bootcamp, which essentially means creating a detailed Word document for each lesson, describing exactly how that lesson should go. I don't know how many content creators do this, since it's a long (and sometimes tedious) process, but I find it essential to make sure each concept and technique is introduced at exactly the...

DevOps: Part 2 It's done! A couple of days ago I finally completed the Game Store system, the distributed .NET Web application that will drive the upcoming .NET Cloud Developer Bootcamp (Is that a good name? Let me know!). I'm amazed by how much the tech has advanced in the .NET and Azure world in the last few years. There's so much going on in this field that I have no idea how folks are solving today's chaotic puzzle to learn cloud development with .NET. I was lucky enough to enter the .NET...

DevOps: Part 1 Wow, getting the Game Store web application deployed to Azure via Azure DevOps was one of the most challenging things I've done so far as part of the .NET Developer Bootcamp project. But, somehow it all worked out, and the end result is really nice. The complexity came from me trying to fit both the Azure infra deployment and the CI/CD process into the .NET Aspire model, which is only poorly supported at this time. But, having worked on dozens of Azure deployments and CI/CD...