Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
Tracked by #26364
javiercn opened this issue Jul 5, 2022 · 3 comments · Fixed by #42692
Closed
Tracked by #26364

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

javiercn opened this issue Jul 5, 2022 · 3 comments · Fixed by #42692
Assignees
Labels
api-approved API was approved in API review, it can be implemented area-blazor Includes: Blazor, Razor Components design-proposal This issue represents a design proposal for a different issue, linked in the description enhancement This issue represents an ask for new feature or an enhancement to an existing one feature-blazor-wasm This issue is related to and / or impacts Blazor WebAssembly feature-blazor-wasm-auth
Milestone

Comments

@javiercn
Copy link
Member

javiercn commented Jul 5, 2022

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","peter@example.com");

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", "peter@example.com");
  });
}

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", "peter@example.com");
  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.
@javiercn javiercn added design-proposal This issue represents a design proposal for a different issue, linked in the description area-blazor Includes: Blazor, Razor Components feature-blazor-wasm This issue is related to and / or impacts Blazor WebAssembly labels Jul 5, 2022
@javiercn javiercn added this to the 7.0-preview7 milestone Jul 8, 2022
@javiercn javiercn modified the milestones: 7.0-preview7, 7.0-rc1 Jul 13, 2022
@javiercn
Copy link
Member Author

javiercn commented Jul 13, 2022

API Review diff and scenarios

API diff

+public enum InteractionType
+{
+  SignIn = 0
+  GetToken = 1
+  SignOut = 2
+}

+ public class InteractiveRequestOptions
+ {
+   public InteractiveRequestOptions();
+   public InteractionType Interaction { get; init; }
+   public string ReturnUrl { get; init; }
+   public System.Collections.Generic.IEnumerable<string> Scopes { get; init; }
+   public System.Collections.Generic.IDictionary<string, object> AdditionalRequestParameters { get; set; }
+ 
+   public static InteractiveRequestOptions GetToken(string returnUrl);
+   public static InteractiveRequestOptions GetToken(string returnUrl, System.Collections.Generic.IEnumerable<string> scopes);
+   public static InteractiveRequestOptions SignIn(string returnUrl);
+   public static InteractiveRequestOptions SignIn(string returnUrl, System.Collections.Generic.IEnumerable<string> scopes);
+   public static InteractiveRequestOptions SignOut(string returnUrl);
+ }

public class AccessTokenNotAvailableException
{
+  void Redirect(System.Action<InteractiveRequestOptions> request);
}

public class AccessTokenResult
{
+  public AccessTokenResult(AccessTokenResultStatus status, AccessToken token, string interactiveRequestUrl, InteractiveRequestOptions interactiveRequest);
+  public InteractiveRequestOptions InteractiveRequest { get; }
+  public string InteractiveRequestUrl { get; }
}

public class RemoteAuthenticationContext<TRemoteAuthenticationState>
{
+  public InteractiveRequestOptions InteractiveRequest { get; set; }
}

public class RemoteAuthenticationService<TRemoteAuthenticationState, TAccount, TProviderOptions>
{
  // Implemented IDisposable with the Dispose pattern
+  protected void virtual Dispose(bool disposing);

  // Added an additional constructor overload with a logger parameter
+  public RemoteAuthenticationService(
+    Microsoft.JSInterop.IJSRuntime jsRuntime,
+    Microsoft.Extensions.Options.IOptionsSnapshot<RemoteAuthenticationOptions<TProviderOptions>> options,
+    Microsoft.AspNetCore.Components.NavigationManager navigation, AccountClaimsPrincipalFactory<TAccount> accountClaimsPrincipalFactory,
+    Microsoft.Extensions.Logging.ILogger<RemoteAuthenticationService<TRemoteAuthenticationState, TAccount, TProviderOptions>> logger)
}

+public static class NavigationManagerExtensions()
+{
+  static void NavigateToLogin(this Microsoft.AspNetCore.Components.NavigationManager manager, string loginPath);
+  static void NavigateToLogin(this Microsoft.AspNetCore.Components.NavigationManager manager, string loginPath, InteractiveRequestOptions request);
+  static void NavigateToLogout(this Microsoft.AspNetCore.Components.NavigationManager manager, string logoutPath);
+  static void NavigateToLogout(this Microsoft.AspNetCore.Components.NavigationManager manager, string logoutPath, string returnUrl);
+}

Scenarios

Customizing sign in requests.

var request = InteractiveRequestOptions.SignIn(Navigation.Uri);
request.AdditionalRequestParameters = new Dictionary<string, object>
{
    ["prompt"] = "login"
};

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

Logging out

  Navigation.NavigateToLogout("authentication/logout");

Getting a token for a different resource using the IAccessTokenProvider

var accessTokenResult = await AuthorizationService.RequestAccessToken(new AccessTokenRequestOptions
    {
        Scopes = new[] { "SecondAPI" }
    });

if (!accessTokenResult.TryGetToken(out var token))
{
    Navigation.NavigateToLogin(accessTokenResult.InteractiveRequestUrl, accessTokenResult.InteractiveRequest);
    return;
}

Getting a token for a different resource as a result of an exception in the authorization message handler

try
{
   await httpclient.Get("/orders");   
}
catch(AccessTokenNotAvailableException ex)
{
  ex.Redirect(requestOptions => 
  {
    requestOptions.AdditionalRequestParameters.Add("login_hint", "peter@example.com");
  });
}

@javiercn javiercn added the api-ready-for-review API is ready for formal API review - https://github.com/dotnet/apireviews label Aug 8, 2022
@ghost
Copy link

ghost commented Aug 8, 2022

Thank you for submitting this for API review. This will be reviewed by @dotnet/aspnet-api-review at the next meeting of the ASP.NET Core API Review group. Please ensure you take a look at the API review process documentation and ensure that:

  • The PR contains changes to the reference-assembly that describe the API change. Or, you have included a snippet of reference-assembly-style code that illustrates the API change.
  • The PR describes the impact to users, both positive (useful new APIs) and negative (breaking changes).
  • Someone is assigned to "champion" this change in the meeting, and they understand the impact and design of the change.

@halter73
Copy link
Member

halter73 commented Aug 8, 2022

API Review Notes:

  • What about the static factory methods on InteractiveRequestOptions?
    • Maybe add "Create" to the beginning of the method names to make it clear they are factories.
    • And add "Options" to the end?
  • Let's used required init for required options and null otherwise.
  • What about this crazy "Redirect" method on an exception?
    • Another overload already exists
  • Does "Redirect" need a Action<InteractiveRequestOptions> or can it take InteractiveRequestOptions directly?
    • Type and ReturnUrl cannot change, so it's nice to prepopulate.
  • Will AccessTokenResult.RedirectUrl be set with the new constructor?
    • Unsure.
    • Do we need to obsolete RedirectUrl and the old ctor?
  • Just no to the disposing pattern.
  • Do we need to make an internal version of RemoteAuthenticationService so we can add a ctor without public API?
    • Not this time. It's a lot of work, and we don't expect it to happen again.
  • InteractiveRequestOptions parameters should be named requestOptions instead of request.
+public enum InteractionType
+{
+  SignIn = 0
+  GetToken = 1
+  SignOut = 2
+}

+ public sealed class InteractiveRequestOptions
+ {
+   public InteractiveRequestOptions();
+   public required InteractionType Interaction { get; init; }
+   public required string ReturnUrl { get; init; }
+   public System.Collections.Generic.IEnumerable<string> Scopes { get; init; }
+   public System.Collections.Generic.IDictionary<string, object> AdditionalRequestParameters { get; set; }
+ 
+   public static InteractiveRequestOptions CreateGetTokenOptions(string returnUrl);
+   public static InteractiveRequestOptions CreateGetTokenOptions(string returnUrl, System.Collections.Generic.IEnumerable<string> scopes);
+   public static InteractiveRequestOptions CreateSignInOptions(string returnUrl);
+   public static InteractiveRequestOptions CreateSignInOptions(string returnUrl, System.Collections.Generic.IEnumerable<string> scopes);
+   public static InteractiveRequestOptions CreateSignOutOptions(string returnUrl);
+ }

public class AccessTokenNotAvailableException
{
+  void Redirect(System.Action<InteractiveRequestOptions> requestOptions);
}

public class AccessTokenResult
{
+  public AccessTokenResult(AccessTokenResultStatus status, AccessToken token, string interactiveRequestUrl, InteractiveRequestOptions interactiveRequest);
+  public InteractiveRequestOptions InteractiveRequest { get; }
+  public string InteractiveRequestUrl { get; }
}

public class RemoteAuthenticationContext<TRemoteAuthenticationState>
{
+  public InteractiveRequestOptions InteractiveRequestOptions { get; set; }
}

public class RemoteAuthenticationService<TRemoteAuthenticationState, TAccount, TProviderOptions>
{
  // Added an additional constructor overload with a logger parameter
+  public RemoteAuthenticationService(
+    Microsoft.JSInterop.IJSRuntime jsRuntime,
+    Microsoft.Extensions.Options.IOptionsSnapshot<RemoteAuthenticationOptions<TProviderOptions>> options,
+    Microsoft.AspNetCore.Components.NavigationManager navigation, AccountClaimsPrincipalFactory<TAccount> accountClaimsPrincipalFactory,
+    Microsoft.Extensions.Logging.ILogger<RemoteAuthenticationService<TRemoteAuthenticationState, TAccount, TProviderOptions>> logger)
}

+public static class NavigationManagerExtensions()
+{
+  static void NavigateToLogin(this Microsoft.AspNetCore.Components.NavigationManager manager, string loginPath);
+  static void NavigateToLogin(this Microsoft.AspNetCore.Components.NavigationManager manager, string loginPath, InteractiveRequestOptions requestOptions);
+  static void NavigateToLogout(this Microsoft.AspNetCore.Components.NavigationManager manager, string logoutPath);
+  static void NavigateToLogout(this Microsoft.AspNetCore.Components.NavigationManager manager, string logoutPath, string returnUrl);
+}

@halter73 halter73 added api-needs-work API needs work before it is approved, it is NOT ready for implementation and removed api-ready-for-review API is ready for formal API review - https://github.com/dotnet/apireviews labels Aug 8, 2022
@javiercn javiercn added api-approved API was approved in API review, it can be implemented and removed api-needs-work API needs work before it is approved, it is NOT ready for implementation labels Aug 12, 2022
@danroth27 danroth27 added the enhancement This issue represents an ask for new feature or an enhancement to an existing one label Aug 24, 2022
@ghost ghost locked as resolved and limited conversation to collaborators Sep 23, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
api-approved API was approved in API review, it can be implemented area-blazor Includes: Blazor, Razor Components design-proposal This issue represents a design proposal for a different issue, linked in the description enhancement This issue represents an ask for new feature or an enhancement to an existing one feature-blazor-wasm This issue is related to and / or impacts Blazor WebAssembly feature-blazor-wasm-auth
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants