C
C#β€’4mo ago
Kyr

Blazor Web Assembly (stand alone) - [Authorize] Attribute not recognising roles straight after login

have a custom AuthenticationStateProvider that adds roles to the ClaimsPrincipal in public override async Task<AuthenticationState> GetAuthenticationStateAsync() As an anonymous user, when I hit a route protected with the [Authorize(Roles="Administrator")] attribute, the app correctly sends me to login and redirects me back to the protected route after login. However, when I log in as a user account that has the "Administrator" role, upon landing back on the protected route it tells me that I don't have permission to access that page (i.e. I'm logged in but don't have the correct role). If I reload the page in the browser, it then recognises that I have the role and lets me in. In my case, I'm adding the authorize attribute to a whole directory using _Imports.razor:
@using Microsoft.AspNetCore.Authorization
@layout AdminLayout
@attribute [Authorize(Roles = "GlobalAdministrator,Administrator")]
@using Microsoft.AspNetCore.Authorization
@layout AdminLayout
@attribute [Authorize(Roles = "GlobalAdministrator,Administrator")]
Not sure what I'm doing wrong.
28 Replies
Crdl
Crdlβ€’4mo ago
Is GetAuthenticationState being called again once you land back on the protected route? I'd log just to make sure there's no weird order of operations bug
Kyr
KyrOPβ€’4mo ago
@Crdl I added an info log at the start of GetAuthenticationStateAsync:
public override async Task<AuthenticationState> GetAuthenticationStateAsync() {
_logger.LogInformation("GetAuthenticationStateAsync() called");
// ... snip ...
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync() {
_logger.LogInformation("GetAuthenticationStateAsync() called");
// ... snip ...
}
In the browser log...
info: Microsoft.AspNetCore.Components.WebAssembly.Authentication.RemoteAuthenticationService[0] GetAuthenticationStateAsync() called info: Microsoft.AspNetCore.Authorization.DefaultAuthorizationService[2] Authorization failed. These requirements were not met: RolesAuthorizationRequirement:User.IsInRole must be true for one of the following roles: (GlobalAdministrator|Administrator)
I did the above, then restarted the Blazor app. 1. Went to my /admin route in the browser 2. Was correctly redirected to login page on my OIDC server 3. Logged in - was redirected back to /admin Then saw the logs above
Crdl
Crdlβ€’4mo ago
Is the first GetAuthenticationState call actually getting what you expect? Can you log in there basically everything you should have? And see how that differs after you refresh
Kyr
KyrOPβ€’4mo ago
Added more logging... It looks like inside the GetAuthenticationStateAsync() call the user.Identity?.IsAuthenticated is returning false on that run.
info: Microsoft.AspNetCore.Components.WebAssembly.Authentication.RemoteAuthenticationService[0] ### GetAuthenticationStateAsync() called invoke-js.ts:176 info: Microsoft.AspNetCore.Components.WebAssembly.Authentication.RemoteAuthenticationService[0] ### We are authenticated: False invoke-js.ts:176 info: Microsoft.AspNetCore.Components.WebAssembly.Authentication.RemoteAuthenticationService[0] ### DONE
public override async Task<AuthenticationState> GetAuthenticationStateAsync() {
_logger.LogInformation("### GetAuthenticationStateAsync() called");
var authState = await base.GetAuthenticationStateAsync();
var user = authState.User;
if (user.Identities.Any(x => x.AuthenticationType == "TemplateProject-api"))
{
_logger.LogInformation("### Found custom identity - returning early");
return (await Task.FromResult(new AuthenticationState(user)));
}


_logger.LogInformation("### We are authenticated: {Authed}", user.Identity?.IsAuthenticated);

List<Claim> claims = [];
if (user.Identity?.IsAuthenticated ?? false)
{
// snip - fetching `profile` from my API and populating `claims` with my custom roles
}

var state = Task.FromResult(new AuthenticationState(user));
if (claims.Count != 0)
{
_logger.LogInformation("### Adding new claims identity to principal: {Claims}", JsonSerializer.Serialize(claims.Select(x => new {x.Type, x.Value})));
user.AddIdentity(new ClaimsIdentity(claims, "TemplateProject-api"));
state = Task.FromResult(new AuthenticationState(user));
_logger.LogInformation("### NotifyAuthenticationStateChanged");
NotifyAuthenticationStateChanged(state);
}

_logger.LogInformation("### DONE");
return (await state);
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync() {
_logger.LogInformation("### GetAuthenticationStateAsync() called");
var authState = await base.GetAuthenticationStateAsync();
var user = authState.User;
if (user.Identities.Any(x => x.AuthenticationType == "TemplateProject-api"))
{
_logger.LogInformation("### Found custom identity - returning early");
return (await Task.FromResult(new AuthenticationState(user)));
}


_logger.LogInformation("### We are authenticated: {Authed}", user.Identity?.IsAuthenticated);

List<Claim> claims = [];
if (user.Identity?.IsAuthenticated ?? false)
{
// snip - fetching `profile` from my API and populating `claims` with my custom roles
}

var state = Task.FromResult(new AuthenticationState(user));
if (claims.Count != 0)
{
_logger.LogInformation("### Adding new claims identity to principal: {Claims}", JsonSerializer.Serialize(claims.Select(x => new {x.Type, x.Value})));
user.AddIdentity(new ClaimsIdentity(claims, "TemplateProject-api"));
state = Task.FromResult(new AuthenticationState(user));
_logger.LogInformation("### NotifyAuthenticationStateChanged");
NotifyAuthenticationStateChanged(state);
}

_logger.LogInformation("### DONE");
return (await state);
}
After hitting reload in the browser:
info: Microsoft.AspNetCore.Components.WebAssembly.Authentication.RemoteAuthenticationService[0] ### GetAuthenticationStateAsync() called invoke-js.ts:176 info: Microsoft.AspNetCore.Components.WebAssembly.Authentication.RemoteAuthenticationService[0] ### We are authenticated: True info: Microsoft.AspNetCore.Components.WebAssembly.Authentication.RemoteAuthenticationService[0] ### Profile: {"Id":"4592defe-abdf-43bd-833d-4dede705b5aa","Email":"[email protected]","Name":"Administrator","Roles":[{"Id":"01J3Z3F1X6AJMXA8AAM0PSWAN0","Name":"GlobalAdministrator"}],"Permissions":["Role:Create","Role:Read","Role:Update","Role:Delete","Role:ManagePermissions","UserProfile:Create","UserProfile:Read","UserProfile:Update","UserProfile:Delete","UserProfile:ManageRoles","Permission:Read"]} invoke-js.ts:176 info: Microsoft.AspNetCore.Components.WebAssembly.Authentication.RemoteAuthenticationService[0] ### Adding roles to claims: http://schemas.microsoft.com/ws/2008/06/identity/claims/role: GlobalAdministrator invoke-js.ts:176 info: Microsoft.AspNetCore.Components.WebAssembly.Authentication.RemoteAuthenticationService[0] ### Adding new claims identity to principal: [{"Type":"http://schemas.microsoft.com/ws/2008/06/identity/claims/role","Value":"GlobalAdministrator"}] invoke-js.ts:176 info: Microsoft.AspNetCore.Components.WebAssembly.Authentication.RemoteAuthenticationService[0] ### NotifyAuthenticationStateChanged info: Microsoft.AspNetCore.Components.WebAssembly.Authentication.RemoteAuthenticationService[0] ### DONE
The issue is that user.Identity?.IsAuthenticated is false at the point where the OIDC server redirects us back to the app I just don't get why since we just logged in
Crdl
Crdlβ€’4mo ago
Just a hunch, maybe just double check your middleware is in the right order on the server?
Kyr
KyrOPβ€’4mo ago
It’s stand alone web assembly. No server. Any more ideas @Crdl - I'm at a complete loss 😦
Crdl
Crdlβ€’4mo ago
@Kyr can I see the rest of your AuthenticationStateProvider?
Kyr
KyrOPβ€’4mo ago
Sure - attached as it's too big for a message
Crdl
Crdlβ€’4mo ago
What's in the base class?
Kyr
KyrOPβ€’4mo ago
That's the default .NET class that is being loaded in web assembly when using OIDC authentication @Crdl I'd happily jump in a voice channel and screen-share if that would help resolve this πŸ™‚
Crdl
Crdlβ€’4mo ago
Am currently at work unfortunately πŸ˜‚
Kyr
KyrOPβ€’4mo ago
Me too ... trying to get this working as a POC template in an attempt to get Blazor adoption in the business. Already had to abandon the .NET 8 InteractiveAuto style Server + Wasm way of doing things because it just doesn't work with remote auth when you need client components to make authenticated requests to an external API... now having different auth problems with pure wasm. It's so frustrating because I can see Blazor's potential... yet it roadblocks me at every turn
Crdl
Crdlβ€’4mo ago
GitHub
aspnetcore/src/Components/WebAssembly/WebAssembly.Authentication/sr...
ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux. - dotnet/aspnetcore
Kyr
KyrOPβ€’4mo ago
Not sure how it can be if I'm overriding GetAuthenticationStateAsync()
Crdl
Crdlβ€’4mo ago
You're fetching the base one on line 27 of what you sent me
Kyr
KyrOPβ€’4mo ago
Yeah, I see. So a bug in Blazor then? Because without my custom provider it would still be doing that
Crdl
Crdlβ€’4mo ago
Hmm, not sure if necessarily a bug. We've not used RemoteAuthenticationService internally we've just inherited from AuthenticationStateProvider directly
Kyr
KyrOPβ€’4mo ago
But then I don't get how... because using OIDC the user is redirected away from the Blazor app to the OIDC provider to log in, then redirected back to the app after logging in. Shouldn't that mean that the Blazor app is re-bootstrapped when the user lands back on it? Which should do the same as if I reload the page. Yet reloading the page causes it to suddenly see the correct authentication It needs to inherit the RemoteAuthenticationService or the OIDC stuff doesn't work
Crdl
Crdlβ€’4mo ago
I was thinking that GetAuthenticationStateAsync could be being called before everything is set up.. but actually if it's just fetching from JS then I have no idea Worth trying to turn this caching off at any rate
Kyr
KyrOPβ€’4mo ago
But the cache isn't persisting anywhere
Kyr
KyrOPβ€’4mo ago
It's just a private field So on re-loading the page either manually or from a redirect back into the app it's a brand new instance of the wasm app so it should be empty the first time through @Crdl Swapped this:
var authState = await base.GetAuthenticationStateAsync();
var user = authState.User;
var authState = await base.GetAuthenticationStateAsync();
var user = authState.User;
for this:
var user = await base.GetAuthenticatedUser();
var user = await base.GetAuthenticatedUser();
Which bypasses the caching in the private GetUser() method of the base class. Same issue 😦 base.GetAuthenticatedUser() is getting the user directly from JSInterop Still getting the log...
info: Microsoft.AspNetCore.Components.WebAssembly.Authentication.RemoteAuthenticationService[0] ### We are authenticated: False
Crdl
Crdlβ€’4mo ago
If you manually call JSRuntime.InvokeAsync(authentication service.getUser) or whatever at that point in the code, what do you get back?
Crdl
Crdlβ€’4mo ago
GitHub
aspnetcore/src/Components/WebAssembly/Authentication.Msal/src/Inter...
ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux. - dotnet/aspnetcore
Kyr
KyrOPβ€’4mo ago
FIXED IT! πŸ₯³ Instead of overriding GetAuthenticationStateAsync() I've changed to overriding GetAuthenticatedUser() instead.
Kyr
KyrOPβ€’4mo ago
Kyr
KyrOPβ€’4mo ago
Virtually the same code, just in a different method and I just return the ClaimsPrincipal now instead of the AuthenticationState I have absolutely no idea why this makes a difference ... the default GetAuthenticationStateAsync() is just calling the GetAuthenticatedUser() under the hood anyway so it should produce the same end result... but here we are
Want results from more Discord servers?
Add your server