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