Skip to content

[Blazor][Wasm] Dynamic and extensible authentication requests #42580

Closed
@javiercn

Description

@javiercn

The new additions offer a loosely coupled model to interact with the underlying authentication service for customizing the authentication details as well as more granular control of the process.

This proposal unblocks:
#37365
#32640
#32782
#19925
#33784

In the initial version of Blazor Webassembly we shipped support for authentication via OAuth supported by oidc-client.js and MSAL.js. Our support allowed people to login, logout and acquire tokens to talk to APIs. All the required parameters were configured statically at startup time.

Over time, we have observed the need from people to dynamically tweak the parameters used for performing these authentication flows. Whether they need to add additional parameters for their provider or pass in additional options to change the login behavior or the behavior when interaction is required to provision a token.

This presented challenges with the current implementation, as all the configuration was defined during startup and there were many options that did not make sense to configure statically, like the login hint or the prompt behavior for the IdP.

In addition to that, adding those options to the existing provider options will add an additional maintenance burden.

In general, there are four scenarios that we want to support:

  • The developer can implement login and log out (provisioning a token as part of that flow).
  • The developer can customize the login and logout flows to force the user to re-enter credentials or change accounts.
  • The user is able to provision a token silently.
  • The user is able to provision a token interactively when it can't be provisioned silently. This can happen for several reasons:
    • The user credentials expired and a new login operation must take place.
    • Acquiring a token for the given scopes requires interaction, for example, if the user needs to consent.
stateDiagram-v2

state "/" as Home
state "authentication/login" as Login
state authenticate <<choice>>
state orders <<choice>>
state "orders/list" as Resource
[*] --> Home
Home --> authenticate
authenticate --> Login : Page requires authenticated user.
authenticate --> Login : User clicks login.
authenticate --> Login : User clicks change account.
Login --> Home : User successfullly authenticates
Home --> orders : User navigates to /orders
orders --> Login : [Not Authorized]
Login --> Resource : [Authorized] User successfully authenticates
orders --> Resource : [Authorized]
Resource --> Login : [Credentials expired]\nProvision new credentials
Loading

Out of those flows, we currently only supported 1 and 3, since we defined the configuration during startup and we relied on the ability to get tokens silently indefinitely in other cases thanks to the AAD ability to consent ahead of time.

Unfortunately, there are scenarios when that functionality doesn't work and in addition to that, the latest best pratices recommend not requesting consent for additional scopes until you need them.

Given the desire to enable these two new scenarios and unblock our customers ability to customize the login process as well as the process for acquiring additional tokens interactively, we are making a few changes to the webassembly authentication system, to better support these concerns.

The "webassembly authentication" protocol

Up to now, we would redirect users to the authentication/login endpoint and we would optionally include a returnUrl parameter in the query string, indicating where the user should be sent back when they completed the flow.

This proved enough to implement basic functionality where the AuthorizeView will work in concert with the RedirectToLogin component in the template to redirect the user to log in and return back to the page.

Similarly, whenever the credentials happened to expire, an exception would be thrown and the user would be redirected to the login endpoint to acquire refreshed credentials and return to the same location.

This approach worked for simple scenarios, but it was not without risks. Passing data through the query string requires us to deal with encoding and decoding the data and can open us to risks like open redirects, etc.

In addition to that, it is hard to pass in structured data (objects) through the query string. There are several approaches, like serializing to JSON and Base64Url encoding the data before putting it on the query string.

To support the new scenarios we care about (customizing the login flow, acquiring tokens interactively, passing in additional data, etc.) we want to allow the developer to pass in parameters to the authentication/login endpoint that can flow to the service.

Many of those parameters might be request specific and not suitable to be part of the default ProviderOptions we offer.

Similarly, we don't want to necessarily allow changing things like the authority or similar parameters on a per request basis, as that sets up customers for failure.

The "upgraded authentication" protocol

To address some of the challenges of the existing protocol, as well as "standarize" in an approach that can enable additional flexibility for future versions, we are going to introduce a new primitive called the InteractiveAuthenticationRequest.

This request represents the contract for the authentication/login endpoint that the <RemoteAuthenticatorViewCore> understands.

classDiagram
class InteractiveAuthenticationRequest{
  string ReturnUrl
  string[] Scopes
  InteractiveAuthenticationRequestType RequestType
  AddAdditionalParameter(string name, TParameter value)
  TParameter GetAdditionalParameter(string name)
}

class InteractiveAuthenticationRequestType{
  <<enumeration>>
  Login
  GetToken
}
Loading

It provides first class support for some of the standard properties that we need for performing the flows, like ReturnUrl and Scopes.

It provides methods to add and retrieve additional parameters that will be passed down to the underlying JS implementation.

In order to pass these parameters to the login endpoint, we are adding support for passing and retrieving state from the NavigationManager when performing internal navigations backed by the history API.

Leveraging the history API offers several benefits:

  • The state that we pass to the endpoint is tied to the navigation we perform to the authentication/login endpoint.
  • Using state in the history API we can avoid having to deal with encoding and decoding data.
  • Using state in the history API reduces the surface attack area, since unlike the query string, it can't be set via a top level navigation nor be influenced from a different origin.
  • Using state in the history API removes the need for cleanup as the state is attached to the entry and goes away upon successful login (because we replace the history entry).

Passing the data to the login endpoint can be done with an extension method on navigation manager that takes care of serializing the data and putting it on the state parameter when navigating:

navigationManager.NavigateToLogin("login/path", interactiveRequest);

With that in mind, the scenarios we care about enabling look like this:

Customize the login process

// Likely will add methods like InteractiveAuthenticationRequest.Login(string returnUrl) tailored for common cases.
var request = new InteractiveAuthenticationRequest(InteractiveAuthenticationRequestType.Authenticate, navigationManager.Uri);

request.AddAdditionalParameter("login_hint","[email protected]");

navigationManager.NavigateToLogin("authentication/login", request);

Customize the options before getting a token interactively

try{
   await httpclient.Get("/orders");
   ...
}catch(AccessTokenNotAvailableException ex)
{
  ex.Redirect(interactiveRequest => {
    interactiveRequest.AddAdditionalParameter("login_hint", "[email protected]");
  });
}

Customize the options when using the IAccessTokenProvider directly

var result = provider.GetAccessToken(new AccessTokenOptions{ Scopes = new[] {"a", "b"}});
if(!result.TryGetToken(out var token))
{
  var interaction = result.InteractiveRequest;
  interactiveRequest.AddAdditionalParameter("login_hint", "[email protected]");
  Navigation.NavigateToLogin(result.InteractiveRequestUrl, interaction);
}

In addition to that, we are going to provide a callback that will be invoked when a given Authentication event happens so that applications can also centralize the logic for passing in additional parameters to the different operations.

That's the reason why we don't expose a IDictionary<string, object> as the state is serialized between the location originating the navigation (for example the index page) to the authentication/login endpoint and deserialized afterwards, which means that the types in the dictionary are lost (replaced for JsonElement) and that would confuse users.

Other information

  • Why not a typed model?
    • It would involve adding an additional generic parameter to our types (and they already have enough).
    • It would require us to define the types ourselves.
    • These scenarios are more advanced, and people can write nice wrappers around them for their library if needed.

Metadata

Metadata

Assignees

Labels

api-approvedAPI was approved in API review, it can be implementedarea-blazorIncludes: Blazor, Razor Componentsdesign-proposalThis issue represents a design proposal for a different issue, linked in the descriptionenhancementThis issue represents an ask for new feature or an enhancement to an existing onefeature-blazor-wasmThis issue is related to and / or impacts Blazor WebAssemblyfeature-blazor-wasm-auth

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions