This document describes options and proposals for #4048.
Principles:
- In Razor Components, enable customers to use as much of ASP.NET Core's existing authentication/authorization/identity feature set as possible. Minimize the invention of new stuff; maximize orthogonality.
- Provide a programming model that can be consistent across Razor Components (server-side) and Blazor (client-side), so components that use authorization can still be portable across the two, even if app-level
Startup.cs
logic is completely different for the two runtime environments.
The Razor Components auth feature set will be mostly the same as for MVC/Pages. We'll get the same long-tail features such as integration with 3rd-party social logins, 2FA, etc. The Razor Components template will support the same four auth options during project creation, and the "Scaffold Identity" feature will be usable on existing Razor Components projects.
Many existing features will be useful as-is in Razor Components:
- Middleware
- Authentication (particularly cookie-based authentication)
- Interaction with external OAuth flows
- Identity
- Database and APIs
- User interface, including flows around 2FA etc.
- Underlying concepts of principals, roles, claims
- System.Security.Claims.ClaimsPrincipal itself, plus APIs for evaluating roles and claims, work everywhere
- Policy APIs will work for Razor Components, but not Blazor (because they are ASP.NET Core-specific)
However, some features will not be usable as-is:
- HttpContext-based APIs, e.g.,
HttpContext.User
- Technically could be used in Razor Components, but we should provide alternatives and dissuade use of
IHttpContextAccessor
, as it has no equivalent in Blazor. Not actually sure if it's even valid to use HttpContext in Azure SignalR anyway. - Expected alternative: new
IAuthenticationState
(see below)
- Technically could be used in Razor Components, but we should provide alternatives and dissuade use of
SignInManager
APIs, e.g.SignOutAsync()
, assume a request-response environment - they try to set cookies or do redirections. So, many of them fail or do nothing in Razor Components or any SignalR hub.- Expected alternative: guidance to use these methods only during redirection-based flows
UserManager
isn't desirable as a way just to find out who's logged in, because it's only available in server-side ASP.NET Core.- Expected alternative:
IAuthenticationState
again, plus guidance to useUserManager
just as a way of interacting with the Identity database.
- Expected alternative:
- Policies live in server-side ASP.NET Core assemblies and won't immediately have any equivalent for Blazor
- Expected alternative: Nothing built-in for Blazor in the first instance. Developers can create their own abstractions for policies if they want.
- Scaffolding identity into existing apps is broken for Razor Components in many ways.
- Expected resolution: we fix the problems.
Outline:
- Offer new built-in components for accessing authentication/authorization state. These involve some new underlying abstractions.
- Ensure seamless compatibility with existing Identity UI
Detailed breakdown follows.
This is a way to obtain and use authentication state in procedural logic. In the project template, we'll wrap one around all the developer's own components, e.g.:
<AuthenticationStateProvider>
<Router ... />
</AuthenticationStateProvider>
This cascades a parameter of type IAuthenticationState
, defined simply as:
namespace Microsoft.AspNetCore.Components
{
public interface IAuthenticationState
{
ClaimsPrincipal User { get; }
// Possibly also in the future:
AntiForgeryTokenSet AntiForgeryTokens { get; }
}
}
Question: Should this really be in the Components namespace? Isn't there any suitable abstractions assembly?
The IAuthenticationState
interface is desirable for forward-compatiblility, instead of supplying a ClaimsPrincipal
directly. As for how <AuthenticationStateProvider>
gets its data, see the section later about IAuthenticationStateProvider
and DI.
Components can therefore receive an IAuthenticationState
, which in turn can change over time. For example, in any component:
[CascadingParameter] IAuthenticationState AuthState { get; set; }
protected override Task OnParametersSetAsync()
{
someData = await MyDb.FetchDataForUserAsync(AuthState.User.Identity.Name);
}
Developers should use the IAuthenticationState
cascading parameter whenever they want to use the authentication state (e.g., username) in procedural logic, or want to evaluate authorization (e.g., roles/claims/policies) in procedural logic.
This implicitly handles dynamic changes of authentication state. If a user logs in/out, changes roles, etc., then the existing [CascadingParameter]
infrastructure will re-render the affected components, re-invoking their OnParametersSetAsync
to get updated results.
This is a convenient way to vary the UI display according to an authorization condition (which as usual can change over time). This only exists because it's convenient in common scenarios - technically we could omit it and tell developers to use the IAuthenticationState
cascading parameter, but <AuthorizeView>
is much easier when you only need to use the information in declarative render output.
<AuthorizeView>
<p>You only see this when you're logged in.</p>
<button onclick="@DoSomething">Some operation that only applies to logged-in users</button>
</AuthorizeView>
<AuthorizeView Roles="admin, staff">
<p>You only see this if you're in at least one of the roles</p>
</AuthorizeView>
<AuthorizeView Claims="claim1, claim2">
<p>You only see this if you have any value for at least one of the claims</p>
</AuthorizeView>
<AuthorizeView Policy="IsACoolDude">
<p>You only see this if you satify the policy</p>
</AuthorizeView>
The exact details of the API (e.g., phrasing as Roles
vs Role
, and-vs-or conditions) should aim for symmetry with the [Authorize]
attribute in MVC.
<AuthorizeView>
<Authorized>
<a href="/identity/user/manage">Hello, @context.User.Identity.Name!</a>
<a onclick="@LogOut">Log out</a>
</Authorized>
<NotAuthorized>
<a href="/identity/user/login">Log in</a>
<a href="/identity/user/register">Register</a>
</NotAuthorized>
<Authorizing>
<img src="spinner.gif" />
</Authorizing>
</AuthorizeView>
All three child content parameters are optional. If Authorized
is provided we use it in preference to ChildContent
, but otherwise ChildContent
is equivalent to Authorized
.
The context
value is the IAuthenticationState
.
Question: Do we also want to add a Redirect="true"
parameter to <AuthenticateView>
that, if the authentication fails, performs a redirection instead of just displaying the NotAuthorized
fragment? If so we need a central place that defines where unauthenticated users get redirected.
NOTE: The details here need further discussion.
This is convenient for page-level, or even directory-level, authorization rules. It's a direct equivalent to MVC's [Authorize]
attribute, which is what most of our customers will instinctively reach for first.
@page "/somepage"
@authorize
<h1>Hello!</h1>
<p>You're authorized.</p>
@authorize-role "admin"
TODO: Can we improve this syntax?
@authorize-policy "IsAJollyUpstandingChapOrChapess"
TODO: Can we improve this syntax?
@authorize false
TODO: Or should that be @allowanonymous
like MVC?
Instead of @authorize
, it's more likely we'll implement a general-purpose @attribute
directive:
@attribute [Authorize(Roles = "admin")]
In order for this to make sense, we also need to move the AuthorizeAttribute to a new abstractions assembly that works both for server and client. Because it would be bad if we introduced a second AuthorizeAttribute
type, since people would pick the wrong one and get confused.
Again, the range of options should be aligned with MVC's [Authorize]
where possible.
The compiler will handle @authorize
by emitting the class attribute [Microsoft.AspNetCore.Components.AuthorizeAttribute]
. We can't use MVC's AuthorizeAttribute
, because it wouldn't make sense on WebAssembly.
Technically, page-level authorization could be achieved by manual procedural logic, or by wrapping an <AuthorizeView>
around the component at the parent level of the hierarchy. But both of these alternatives are inconvenient. Neither come close to the convenience of defining a rule at the level of a whole subdirectory, which is what directives achieve so simply.
Since the actual authorization checks for @authorize
will be performed at runtime by the Router
component, it makes sense to configure the handling of authorization on the Router
.
New parameters for <Router>
:
AuthorizingComponent
specifies which component to display while authorization is in progress (out of the box, that only applies to Blazor). If no value is specified, the render output is empty while in progress.AccessDeniedComponent
specifies which component to display when the authorization condition is not met. If no value is specified, it's hard-coded to render the fixed stringAccess denied
.
These parameters fit into the existing pattern alongside FallbackComponent
(which says what to display in the "page not found" case).
Question: What about when authorization fails and the user isn't yet authenticated? In that case we probably want to redirect to a login page. How does the developer configure this? Is it another parameter? Is there a central place to configure the redirection URL that can be shared with <AuthorizeView>
?
All the APIs above aim for eqivalence between server-side and client-side apps. However, the way that the authentication state data is obtained will be completely different in the two runtime environments:
- For Razor Components out-of-the-box, we'll simply get the authentication state from
HttpContext.User
(or someCircuitHost
property). This means automatic integration with nearly all of ASP.NET Core's infrastructure. The data will be fixed for the duration of each circuit, because most of our infrastructure is built around that assumption being true for a given request/response cycle.- For advanced custom Razor Components apps, developers will be able to implement their own
IAuthenticationStateProvider
that lets the authentication state change over time by some custom method (e.g., a popup auth flow).
- For advanced custom Razor Components apps, developers will be able to implement their own
- For client-side Blazor out-of-the-box, templates will contains the source for a
ServerAuthenticationStateProvider
that works by making an HTTP request toGET /auth/user
, which returns whatever auth-related data the developer wants (by default, just the username). This assumes the server uses cookie-based auth with same-site cookies.- For advanced custom apps, developers could again implement some UI by which auth state can change over time without a page reload (e.g., a popup auth flow that returns a JWT).
To meet these needs, here's IAuthenticationStateProvider
:
namespace Microsoft.AspNetCore.Components
{
public interface IAuthenticationStateProvider
{
Task<IAuthenticationState> GetAuthenticationStateAsync(bool forceRefresh);
// TODO: Declare a custom delegate type
event EventHandler<IAuthenticationState> AuthenticationStateChanged;
}
}
The built-in components (<AuthenticationStateProvider>
and <AuthorizeView>
), plus @authorize
, will all work by getting their data from IAuthenticationStateAccessor
.
TODO: Do we make IAuthenticationStateAccessor exist in the DI setup by default, like some of the other component services, or does it require a services.UseComponentsAuthentication
gesture? I lean towards putting it there by default for Razor Components, but it has to be explicit for Blazor because there's no framework-supplied implementation.
ASP.NET Core's existing Identity system already covers the vast majority of our requirements for user/role/etc management, tie-in with external authentication systems, 2FA, and more. There's no chance we want to reimplement all that or make a new competing alternative.
The goal:
- The Razor Components template should offer "Individual user accounts" auth option (as well as the other three), and with this, set out a sensible file structure for using the Identity UI alongside Razor Components
- For existing apps, we need to support the "Scaffold identity" gesture in VS
Much of this will just work seamlessly, but we'll have to fix the following problems:
- The scaffold process initially fails during compile (doesn't seem to use the new Razor components compiler)
- Can workaround by temporarily removing references to Razor Components from
Startup.cs
, but we need to fix whatever's gone wrong
- Can workaround by temporarily removing references to Razor Components from
- The scaffolded output includes
~/Pages/Shared/_Layout.cshtml
which usesIHostEnvironment
, which is obsolete. - It breaks Razor Components because it adds a
_ViewStart.cshtml
that applies an incorrect layout. - It breaks Razor Components because
services.AddDefaultIdentity<IdentityUser>()
inIdentityHostingStartup.cs
somehow interferes with the pipeline and causes the / URL to return a 404.- Workaround: Move the code from
IdentityHostingStartup
intoConfigureServices
inStartup.cs
. Presumably it's an ordering issue.
- Workaround: Move the code from
- The default code from
IdentityHostingStartup
doesn't include.AddDefaultUI(UIFramework.Bootstrap4)
, but this is needed otherwise the CSS isn't applied properly and it all gets mangled - The scaffolder doesn't trigger the
Add-Migration CreateIdentitySchema
andUpdate-Database
commands, so you have to do it manually otherwise you get an error. This isn't very friendly (and is actually quite hard to discover the solution, as the error given is justThe login failed. Login failed for user 'YOURDOMAIN\you'
). - The scaffolder doesn't add
app.UseAuthentication();
andapp.UseAuthorization();
toStartup.cs
. Until you realise you have to do that, logins just have no effect. - In the Blazor Hosted template, the file
/css/site.css
clashes with a file with identical URL in the Identity UI, so you can't load both at once.- Fix: We should rename the one in the Blazor Hosted template.
- Slightly off-topic: the Blazor Hosted template doesn't use Endpoint Routing yet. Doesn't block anything, but we should update it at some point.
- At a higher level, it's not very nice that the scaffolded pages all go into
Pages
. It would be much better if they went intoAreas/Auth
, since we'll want the top-levelPages
to mean "Razor Components with@page
" later on. We should change this now if we can.
Also, I didn't measure this scientifically, but it feels like once the scaffolded Identity files are in the app, it makes compilation take much longer. I don't know why.
While prototyping the plan above, I hit various problems that could be worked around, but we'll want to make better for actual customers.
- It's hard to have links into the Identity UI (e.g.,
/Identity/Account/Login
), because the client-side router tries to handle them.- We should add a feature like
<a href="..." skip-client-router="true">
(would be similar toprevent-default
orstop-propagation
). - Alternatively we add some extra config options to
<Router>
so you can make it ignore certain patterns. - I temporarily worked around this by using a custom fallback component that redirected to the target page.
- We should add a feature like
- It's hard to send people to the Identity "log out" endpoint, because it requires an antiforgery token.
- To fix this more generally, we should consider putting any active antiforgery token inside the
ClaimsPrincipal
as a claim. Alternatively, make it a separate property onIAuthenticationState
. Either way, then both Razor Components and Blazor apps can find it in the same place. - We also need some way for Razor Components / Blazor to do a "redirect via form post", since that's how the Identity endpoint expects to receive the token. It's currently possible via JS interop, but we should probably bake in an API for this.
- To fix this more generally, we should consider putting any active antiforgery token inside the
In the initial 3.0 release, we want to enable certain core scenarios by default, and have the right low-level pieces that other custom scenarios could be built with. I propose we keep the following out of scope:
- Any auth flow other than redirection based.
- e.g., we don't built in any support for popup-based flows
- First reason: our existing authentication middleware is built around the idea that it runs early in an HTTP request. Things assume it just happens once, and the state doesn't change thereafter. As such we don't have any easy way to make it recompute
HttpContext.User
during the middle of a circuit lifetime (and how could it? the cookie values can't change). Other things may rely on the assumption that it's fixed for a given request. - Second reason: for a popup to pass back the result to the host app, we either have to do it server-side or client-side. Either way it's solvable but has new complications.
- Server-side means the popup completion handler somehow knows about which circuit should be affected and mutates it directly. But what if it's on a different server? And what if we don't want to expose the circuit ID to the 3rd-party auth flow?
- Client-side means we use JS to call
window.opener
with the resulting token, but this undermines the idea of an HTTP-only cookie.
- I know that redirect-based flows come at some cost for a SPA, in that you lose state. But we argue that for any state that's really important enough to preserve, you do so manually (e.g., in a DB, in localStorage, or in Session). Because for most apps, you won't really need to preserve any such state. The only likely case is something like a shopping cart, which you can handle manually.
- Any built-in support for non-cookie-based auth.
- So, if you want to get a JWT from your own server or some external server, we leave it up to you to do that and to implement a suitable
IAuthenticationStateProvider
. This is true both for Razor Components and Blazor, which would in turn have further different requirements for this.
- So, if you want to get a JWT from your own server or some external server, we leave it up to you to do that and to implement a suitable
- Sharing layouts with the Identity UI, except what we get for free from Razor Pages. Basically we expect developers to accept that their Identity pages will tend to look very different, except where they do manual work to align the styling.
- Any built-in auth support at all in the Blazor Standalone template. What would you authenticate against?
- Question: Do we need some way to make circuits disconnect automatically once the corresponding auth token expires? Or what if the user's roles are changed/revoked while their circuit exists? Arguably this is a feature that (if implemented) should be at the SignalR layer - there's already an open feature request for that. Otherwise we are leaving it up to the developer to re-check auth conditions sometimes (e.g., by querying a DB to determine if the connected user still does have the role needed to perform some operation).
- Possibility: Have
ComponentsHub
register a timer that, every minute or so, calls the registeredIAuthenticationStateProvider
'sGetAuthenticationStateAsync(true)
. The built-in Razor Components implementation ofIAuthenticationStateProvider
will somehow re-run the authentication logic against the auth cookie value on theHttpContext
. If the token has expired, raise theAuthenticationStateChanged
event. This will cause the app to change what UI is displayed and change the procedural logic that runs if it makes use of the auth state. - Note that we can't do this only when there's an incoming message to the
ComponentsHub
, because perhaps the event that triggers the UI update is purely server-side and not based on a user action. - Still, there's a good case to make that SignalR itself should be responsible for re-verifying authentication state periodically or at least exposing an API that developers can call to trigger this.
- Feedback from Barry This approach looks good. We should look at the
ValidatePrincipal
event as something to invoke to make cookie revalidation happen. TheIAuthenticationStateProvider
needs to have some way to be told to recompute theClaimsPrincipal
based on whatever data it originally received and whatever external info is used with this (e.g., some data store giving user->claims mappings), the latter of which is whatValidatePrincipal
deals with. Maybe theGetAuthenticationStateAsync(forceRefresh: true)
API will cover this. If we're using a timer-based approach for rechecking auth, the interval needs to be developer-controlled. As per Dan's feedback, we probably also want to make it possible to revalidate on every incoming event, not only the timer (in case the timer interval is say 20 mins).
- Possibility: Have
After a review, break this down into work item issues. Give them all a common label, or possibly create a higher-level tracking issue listing them all with checkboxes.