Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

App Role adjustments from App registrations in Entra are not adopted in Blazor application #3158

Open
habex-ch opened this issue Dec 3, 2024 · 21 comments

Comments

@habex-ch
Copy link

habex-ch commented Dec 3, 2024

Microsoft.Identity.Web Library

Microsoft.Identity.Web

Microsoft.Identity.Web version

3.2.0

Web app

Sign-in users

Web API

Not Applicable

Token cache serialization

Not Applicable

Description

I have an ASP.NET Core Blazor server application with a corresponding app regististration in Microsoft Entra. I configured 2 app roles in the App registration and assigned those app roles to our users.

Then I integrate Microsoft.Identity like this

builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd"));

If a user already is signed-in and I then removethe first app role and assign another role to the user, the already signed in user still has the first role assigned.

Even if I restart the browser he always keeps the first role and never get the second role.

Even if I block the user from signing in AzureAD he still is signed in and never gets locked out.

I investigated the whole thing and it seems to be an issue with the cookie authentication. When I delete the cookie ".AspNetCore.Cookies" it gets the new role assigned and the old one removed it its claims.
I can set the ExpireTimeSpan to 1 day like

    builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
        //.AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd"));
        .AddMicrosoftIdentityWebApp(options =>
        {
            builder.Configuration.GetSection("AzureAD").Bind(options);
        }, cookieAuthOptions =>
        {
            cookieAuthOptions.ExpireTimeSpan = TimeSpan.FromDays(1);//Default is 14 days
        });

But if the user keeps his browser open due to SlidingExpiration the cooke gets renewed indefinitly.

For ASP.NET Core Identity there is a way to force the validation of the claims as is explained in this link: https://learn.microsoft.com/en-us/aspnet/core/security/authentication/identity-configuration?view=aspnetcore-8.0#isecuritystampvalidator-and-signout-everywhere

But there is no way to this with Microsoft.Identity. This is a real security concern.

There should be a way to force the user validation against AzureAD in an interval.

Reproduction steps

  1. Configure an app registration with app roles "MyFirstRole" and "MySecondRole"
  2. Assign app role "MyFirstRole" to a the user
  3. Take the sample app BlazorWebAppEntra and configure it with the app registration as is explained in Secure an ASP.NET Core Blazor Web App with Microsoft Entra ID
  4. Start the application
  5. Start a browser and change the start-settings to "Open tabs from the previous sesson" (see instruction)
  6. Remove the app role and assign the app role "MySecondRole" from the user
  7. See if user keeps the claims of the "MyFirstRole" app role
  8. Restart app, revoke all users sessions in entra even block the user from signing-in
  9. See if user keeps the claims of the "MyFirstRole" app role

Error message

No response

Id Web logs

No response

Relevant code snippets

See above in the description

Regression

No response

Expected behavior

  1. Changes in AzureAD should be enforced in the app at least in an interval.
  2. Revoked sessions in AzureAD (like https://learn.microsoft.com/en-us/powershell/module/azuread/revoke-azureaduserallrefreshtoken?view=azureadps-2.0) should be enforced in an app as well
@habex-ch habex-ch changed the title Role adjustments are not adopted App Role adjustments from App registrations in Entra are not adopted in Blazor application Dec 3, 2024
@michiproep
Copy link

I think there are two parts to this issue:

  1. Even if it was a regular web app, e.g. MVC, you whould need to update the user's roles every once in while manually in your code. We usually do this during cookie's sliding window event
  2. Blazor works differently. Once singed in, the user has a websocket connection to your server. the requests never pass your regular request pipeline which you setup with app.useXY. I did point out this "feature" already during .net8 release conf. Not sure what the best solution for Blazor currently would look like. That's actually one of the reasons we don't use Blazor currently

@habex-ch
Copy link
Author

habex-ch commented Dec 4, 2024

  1. Even if it was a regular web app, e.g. MVC,

You are right. This is even worse.
A MVC web application has the issue. The SlidingExpiration renews the cookie indefinitly.

Microsoft.Identity givs a false sense of security. Revoking user sessions in Entra or blocking a user from signing-in does nothing to an application as long as cookie authentication is in place.

you whould need to update the user's roles every once in while manually in your code. We usually do this during cookie's sliding window event

Do you mean, that

  1. we need a user table in our database
  2. implement a custom synchronisation process between Entra and this user table to track changes of each user
  3. implement a custom cookie validator?

This seems not straight-forward at all. Do you have an example for this?

@michiproep
Copy link

Microsoft.Identity givs a false sense of security.

No, I don't think so. One just really has to know how things work behind the scenes...

Do you mean, that
1. we need a user table in our database
2. implement a custom synchronisation process between Entra and this user table to track changes of each user
3. implement a custom cookie validator?

It doesn't matter where you get your roles from. If it's from azure ad, you can use graph api.
The roles which are "persisted" in the cookie basically work like cache. It all comes down to the "main problem" in the world of IT: "When to invalidate a cache?" :-)
In case of Blazor... well, I don't know what and where would be a good place to do that...

@habex-ch
Copy link
Author

habex-ch commented Dec 4, 2024

Microsoft.Identity givs a false sense of security.

No, I don't think so. One just really has to know how things work behind the scenes...

Maybe, but I didn't find anything in the documentation about this. I found it by accident.
When using Microsoft.Identity I shouldn't care about how the things work behind the scene. But this is another discussion.

Do you mean, that

  1. we need a user table in our database
  2. implement a custom synchronisation process between Entra and this user table to track changes of each user
  3. implement a custom cookie validator?

It doesn't matter where you get your roles from. If it's from azure ad, you can use graph api. The roles which are "persisted" in the cookie basically work like cache. It all comes down to the "main problem" in the world of IT: "When to invalidate a cache?" :-) In case of Blazor... well, I don't know what and where would be a good place to do that...

Well, I agree with you, it is sort of a cache issue.

How do you solve it in a MVC application? Do I have to do my 3 steps above? Do you have an example for MVC?

@michiproep
Copy link

How do you solve it in a MVC application? Do I have to do my 3 steps above? Do you have an example for MVC?

Something like

o.Events.OnCheckSlidingExpiration = async ctx =>
{
    if (ctx.ShouldRenew)
    {
        try
        {
            await updateClaimsAndRoles(ctx.HttpContext, ctx.Principal!);//update the ClaimsPrincipal
        }
        catch
        {
            ctx.ShouldRenew = false;
        }
    }
};

@habex-ch
Copy link
Author

habex-ch commented Dec 5, 2024

How do you solve it in a MVC application? Do I have to do my 3 steps above? Do you have an example for MVC?

Something like

o.Events.OnCheckSlidingExpiration = async ctx =>
{
    if (ctx.ShouldRenew)
    {
        try
        {
            await updateClaimsAndRoles(ctx.HttpContext, ctx.Principal!);//update the ClaimsPrincipal
        }
        catch
        {
            ctx.ShouldRenew = false;
        }
    }
};

Ok. Thank you for this code piece.

I found React to back-end changes in the cookie documentation.

There are also some interesting properties signInSessionsValidFromDateTime and lastPasswordChangeDateTime in the Microsoft Graph documentation for users that could be synchronized to the user table so that those could be validated in o.Events.OnCheckSlidingExpiration.

This all can be used for MVC applications but still does not solve the problem in Bazor Web App (Server and Webassembly).

This all is at best a workaround for an underlying problem. I still think Microsoft.Identity should handle changed users and especially revoked user sessions in Entra.

@michiproep
Copy link

I still think Microsoft.Identity should handle changed users and especially revoked user sessions in Entra.

Well... Not sure. This Lib is more for getting, caching and using tokens. What you can do is using a server-side ticket store for your cookies and a background worker for deleting these entries if "something" changed for your users.
But we decided that that SlidingWindow is good enough for us.

For Blazor, you will have to mess around with this: https://learn.microsoft.com/en-us/aspnet/core/blazor/security/authentication-state?view=aspnetcore-9.0&pivots=server
But I could never figure out how to use it in a clean way as with MVC.
There should be something like a "Blazor/WebSocket request pipeline" where one could hook to just like for normal http requests

@habex-ch
Copy link
Author

habex-ch commented Dec 5, 2024

Lots of possibilities for workarounds.
Well lets wait what Microsoft will say about all this.

@jmprieur
Copy link
Collaborator

jmprieur commented Dec 5, 2024

You are right that the roles, are not re-evaluated until a token is re-issued by ESTS, which happens when tokens expire.
Continuous access evaluation is not yet available for you to use in your applications.

As a mitigation, did you try to use a OpenIdConnectOptions,Prompt = "login", to ensure that the user is forced to re-login, when you "restart the browser"?

@habex-ch
Copy link
Author

habex-ch commented Dec 9, 2024

You are right that the roles, are not re-evaluated until a token is re-issued by ESTS, which happens when tokens expire. Continuous access evaluation is not yet available for you to use in your applications.

Is Continuous access evaluation planed for the (near) future?

As a mitigation, did you try to use a OpenIdConnectOptions,Prompt = "login", to ensure that the user is forced to re-login, when you "restart the browser"?

I just tried it, but it does not force a relogin after a browser restart (if the browser is configured use the previous browser session) instead it just force a relogin after the ExpireTimeSpan is timed out if the SlidingExpiration did not create a new cookie first.

This may help only for the "approle" assignment-issue if I lower the ExpireTimeSpan to a few hours and hope that the user does not try it in this time again.

But a user can still be signed-in forever with the same approle and maybe even with a deleted user.

@Kumima
Copy link

Kumima commented Dec 11, 2024

This is what I explored, may not precise, just want to discuss.
I was wondering about this topic, and I even imagined this before I started. Things can be different depending on the structure you choose. Basic vs BFF(Backend for Frontend).

Basic

The problem here is, the OIDC authentication stores the user claims into the cookie. The framework retrieves claims from cookie if your cookie has not expired, and the cookie won't react to back-end. What I'm planning here is to validate user's role through CookieAuthenticationEvents.OnValidatePrincipal. And I think I'll choose to use GraphAPI to get user's roles and if they aren't match, reject the principal.

Thing can be more complicated for Blazor Server because the user's claims are stored in the AuthenticationState, the CookieAuthenticationEvents.OnValidatePrincipal does not work unless you force load the page. I think the only way for this is to use the RevalidatingAuthenticationStateProvider, and validate roles(through Graph API like above) by interval.

BFF

We use the access token to get access to our backend API. And the library will store the token into cache, I guess the access token will keep role claims until it's expired. I'm not sure, but the only way I can imagine is to custom the JWT validation, and use the above GraphAPI way.

Common Way?

There is always a common way, custom role checking and check role for each operation. For each operation, this needs to call GraphAPI or hits your database if you custom the role store/checking. And it will be safest I think, since you are not going to use any kind of cache. It's just more complex.

@habex-ch
Copy link
Author

habex-ch commented Dec 11, 2024

@Kumima thank you for your thoughts about this topic.

I think your possible solutions for each scenario shows why this should be handled my Microsoft.Identity/ASP.NET Core in a common way and not each project-team figuring it out by themself. We use e.g. Blazor Server without BFF I could implement something as you mentioned above. But I don't think handling authentication/authorization by myself is a good approach.

Common Way?

There is always a common way, custom role checking and check role for each operation. For each operation, this needs to call GraphAPI or hits your database if you custom the role store/checking. And it will be safest I think, since you are not going to use any kind of cache. It's just more complex.

Calling Microsoft Graph each time whould cause latency and other performance issues in the application.

Microsoft themself said somewhere in the documentation, that we shouldn't implement our own authorization mechanism but if we would check each operation by itself we just would do that.

By the way, Microsoft has launched the Microsoft Secure Future Initiative. The current solution does not implment the following key principal

Secure by default:

Security protections are enabled and enforced by default, require no extra effort, and aren’t optional.

@michiproep
Copy link

Hi @habex-ch ,
I don't agree with this for multiple reasons:

I think your possible solutions for each scenario shows why this should be handled my Microsoft.Identity/ASP.NET Core in a common way and not each project-team figuring it out by themself

Authentication and Authorization are two different beasts. Most systems I know don't even use their AD-based groups or roles.
The fact that if you have many roles in your system would exceed a certain token size and in terms a certain cookie size if you don't use a server-side cookie store.

We use the login just for authentication which creates a claimsPrincipal with one identity. Within events tokenValidated and slidingWindow we enrich the claimsPrincipal with a second identity which holds the roles.
This identity can be updated as often and as fast as you like (of course somehow different in Blazor). You can fetch roles on each request from your own cache and invalidate that cache whenever you feel it is necessary.
For our company I did build a complete wrapper around Microsoft.Identity.Web which handles our role system.
Also, sometimes roles - or better permissions - have to be calculated based on other data, may-be per request data. Also, not only roles might matter but other claims (see AuthorizationPolicy examples)

@habex-ch
Copy link
Author

habex-ch commented Dec 11, 2024

Hi @habex-ch , I don't agree with this for multiple reasons:

I think your possible solutions for each scenario shows why this should be handled my Microsoft.Identity/ASP.NET Core in a common way and not each project-team figuring it out by themself

Authentication and Authorization are two different beasts. Most systems I know don't even use their AD-based groups or roles. The fact that if you have many roles in your system would exceed a certain token size and in terms a certain cookie size if you don't use a server-side cookie store.

We use the login just for authentication which creates a claimsPrincipal with one identity. Within events tokenValidated and slidingWindow we enrich the claimsPrincipal with a second identity which holds the roles. This identity can be updated as often and as fast as you like (of course somehow different in Blazor). You can fetch roles on each request from your own cache and invalidate that cache whenever you feel it is necessary. For our company I did build a complete wrapper around Microsoft.Identity.Web which handles our role system. Also, sometimes roles - or better permissions - have to be calculated based on other data, may-be per request data. Also, not only roles might matter but other claims (see AuthorizationPolicy examples)

Hi @michiproep,
That is excactly what I meant. You created your own authorization "layer". You must have invested a lot of time and resources in building that wrapper. This may be worth doing in a huge application.

But we are build small LOB-applications (LOB = Line-of-Business). We don't have time doing this. We also want to use the default authorization implementation from ASP.NET Core. We don't have time doing a deep dive into claimsPrincipal, cookies and stuff like that. That should be abstracted in ASP.NET Core (as most of it is already). We want to focus on the business side of our applications and not the technical stuff.

@habex-ch
Copy link
Author

@jmprieur: What are the next steps with this issue?

@habex-ch
Copy link
Author

habex-ch commented Jan 8, 2025

Thing can be more complicated for Blazor Server because the user's claims are stored in the AuthenticationState, the CookieAuthenticationEvents.OnValidatePrincipal does not work unless you force load the page. I think the only way for this is to use the RevalidatingAuthenticationStateProvider, and validate roles(through Graph API like above) by interval.

I tried a few steps in my Blazor Server App with a custom RevalidatingAuthenticationStateProvider but the method ValidateAuthenticationStateAsync was never called. It seems that the AuthenticationStateChanged event was never triggered. No idea why not.

@Kumima: Do you have a working example for using a custom RevalidatingAuthenticationStateProvider?

@jmprieur: I think there should be a RevalidatingAuthenticationStateProvider for using with Microsoft Identity like you have added here for Individual User authentication. Or is there a reason why you added this only for individual local user authentication in the Startup.cs?

Further information about RevalidatingServerAuthenticationStateProvider.

@Kumima
Copy link

Kumima commented Jan 11, 2025

@habex-ch

internal sealed class DemoRevalidatingAuthenticationStateProvider(ILoggerFactory loggerFactory)
    : RevalidatingServerAuthenticationStateProvider(loggerFactory)
{
    protected override TimeSpan RevalidationInterval => TimeSpan.FromSeconds(5);

    protected override Task<bool> ValidateAuthenticationStateAsync(
        AuthenticationState authenticationState,
        CancellationToken cancellationToken)
    {
        // Here comes your custom logic to revalidate the authentication state.
        Console.WriteLine("Revalidating authentication state.");
        return Task.FromResult(true);
    }
}

builder.Services.AddScoped<AuthenticationStateProvider, DemoRevalidatingAuthenticationStateProvider>();

Just as simple as this. I've checked that there is the console output every 5 seconds.

@michiproep
Copy link

That looks good but it's not bound to requests from the browser.
How does that behave if you have multiple tabs in the browser? Is there only a single RevalidatingServerAuthenticationStateProvider per user or is it per application instance?
Is there a way to trigger per request instead of Interval?

we should also somehow consider to try to sync this with the cookie behaviour. You probably don't want the cookie to still hold an active session or role claims if the RevalidatingServerAuthenticationStateProvider wants to trigger a logout or changes the roles of the user. Plus the other way around: If I delete my cookie I want to be in a clean, loggedOut state.

@Kumima
Copy link

Kumima commented Jan 11, 2025

@michiproep That's what the CookieEvents.OnValidPrinciple should do. The OnValidPrinciple only triggers before the Blazor circuits establish, such as you have a fully reload navigation.

Once the circuit has been established, the claims are stored in the circuit. The Revalidation thing handles the validation and It's a per tab thing.

@habex-ch
Copy link
Author

habex-ch commented Jan 20, 2025

internal sealed class DemoRevalidatingAuthenticationStateProvider(ILoggerFactory loggerFactory)
: RevalidatingServerAuthenticationStateProvider(loggerFactory)
{
protected override TimeSpan RevalidationInterval => TimeSpan.FromSeconds(5);

protected override Task<bool> ValidateAuthenticationStateAsync(
    AuthenticationState authenticationState,
    CancellationToken cancellationToken)
{
    // Here comes your custom logic to revalidate the authentication state.
    Console.WriteLine("Revalidating authentication state.");
    return Task.FromResult(true);
}

}

builder.Services.AddScoped<AuthenticationStateProvider, DemoRevalidatingAuthenticationStateProvider>();
Just as simple as this. I've checked that there is the console output every 5 seconds.

@Kumima, thank you very much for this simple example. I found out where I made the mistake. I tried to integrate it directly in my PersistingAuthenticationStateProvider where I inherited from AuthenticationStateProvider instead of using RevalidatingServerAuthenticationStateProvider. I now have a working PersistingIdentityRevalidatingAuthenticationStateProvider

@habex-ch
Copy link
Author

For your MVC and Blazor applications I implemented now the following things

  1. For MVC and Blazor: Revalidation of cookies with my own class inherited from CookieAuthenticationEvents. It checks periocally with Microsoft Graph if the user is is revoked (in Entra) or the password has changed or one of the role assignments has changed. If so the user is rejected and logged out.
  2. For Blazor: Revalidation of the AuthenticationState with my own class inherited from RevalidatingServerAuthenticationStateProvider. It checks also periocally with Microsoft Graph if the user is is revoked (in Entra) or the password has changed or one of the role assignments has changed. If so the user is rejected and logged out.

The revalidation of the cookie is also required in Blazor because it the RevalidatingServerAuthenticationStateProvider reject the validation it would else directly relogin the user again because the cookie is still valid (as is mentioned above by @michiproep and @Kumima ).
I use the same IMemoryCache for caching the user information (and the role assigments) gotten by MicrosoftGraph. So the cookie whould also be invalid at the next call of ValidatePrincipal when it the authenticationState was invalid.

The logout for role assigments changes is maybe not the perfect solution but it works. Better would be updating the claims as @michiproep mentioned above but I don't get the time for that right now.
@JonPSmith made a good blog post about updating the cookie claims periodically (https://www.thereformedprogrammer.net/advanced-techniques-around-asp-net-core-users-and-their-claims/#3a-refreshing-the-claims-in-a-cookie). This may be a good approach for refreshing the claims if the role assigments changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

4 participants