Skip to content

Instantly share code, notes, and snippets.

@SteveSandersonMS
Last active September 28, 2022 19:30
Show Gist options
  • Save SteveSandersonMS/60ca3a5f70a7f42fba14981add7e7f79 to your computer and use it in GitHub Desktop.
Save SteveSandersonMS/60ca3a5f70a7f42fba14981add7e7f79 to your computer and use it in GitHub Desktop.
Authentication and Authorization

This document describes options and proposals for #4048.

Overall goals

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.

Existing ASP.NET Core features

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)
  • 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 use UserManager just as a way of interacting with the Identity database.
  • 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.

Proposed plan

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.

1. New standard component: AuthenticationStateProvider

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.

2. New standard component: AuthorizeView

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.

Example: Display something only when logged in

<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>

Example: Display something only when meeting criteria

<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.

Example: Multiple possible render outputs

<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.

3. New directive: @authorize

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.

Example: Requiring authorization

@page "/somepage"
@authorize

<h1>Hello!</h1>
<p>You're authorized.</p>

Example: Requiring a role

@authorize-role "admin"

TODO: Can we improve this syntax?

Example: Requiring a policy

@authorize-policy "IsAJollyUpstandingChapOrChapess"

TODO: Can we improve this syntax?

Example: Overriding a higher-level authorize rule

@authorize false

TODO: Or should that be @allowanonymous like MVC?

Proposed design as per meeting

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.

Options and behavior

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.

Notes

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.

Configuration on Router

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 string Access 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>?

4. New service, IAuthenticationStateProvider

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 some CircuitHost 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 client-side Blazor out-of-the-box, templates will contains the source for a ServerAuthenticationStateProvider that works by making an HTTP request to GET /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.

5. Ensure compatibility with Identity

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
  • The scaffolded output includes ~/Pages/Shared/_Layout.cshtml which uses IHostEnvironment, 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>() in IdentityHostingStartup.cs somehow interferes with the pipeline and causes the / URL to return a 404.
    • Workaround: Move the code from IdentityHostingStartup into ConfigureServices in Startup.cs. Presumably it's an ordering issue.
  • 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 and Update-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 just The login failed. Login failed for user 'YOURDOMAIN\you').
  • The scaffolder doesn't add app.UseAuthentication(); and app.UseAuthorization(); to Startup.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 into Areas/Auth, since we'll want the top-level Pages 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.

6. Fix more rough edges

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 to prevent-default or stop-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.
  • 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 on IAuthenticationState. 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.

Scope limits (AKA possible future enhancements)

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.
  • 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 registered IAuthenticationStateProvider's GetAuthenticationStateAsync(true). The built-in Razor Components implementation of IAuthenticationStateProvider will somehow re-run the authentication logic against the auth cookie value on the HttpContext. If the token has expired, raise the AuthenticationStateChanged 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. The IAuthenticationStateProvider needs to have some way to be told to recompute the ClaimsPrincipal 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 what ValidatePrincipal deals with. Maybe the GetAuthenticationStateAsync(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).

Next steps

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.

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