Search code examples
blazor-webassembly

Blazor WASM - Spending a long time initially in Authorizing component


My Blazor WASM (hosted) app is spending quite a long time (~10s) in the authentication process when I open the webpage for the first time and eventually logs

info: Microsoft.AspNetCore.Authorization.DefaultAuthorizationService[2]
      Authorization failed. These requirements were not met:
      DenyAnonymousAuthorizationRequirement: Requires an authenticated user.

Afterwards, it continues to show all content which is not wrapped in an Authorized tag.

In App.razor I use the Authorizing component and it shows its content ("Determining session state...") for a long time until it continues (see App.razor below).

The authentication in my app happens through an endpoint /authentication/login which forwards the user to Auth0 as identity provider using the

<RemoteAuthenticatorView Action="@Action">

component and the configuration in Program.cs

builder.Services.AddOidcAuthentication(options =>
{
    builder.Configuration.Bind("Auth0", options.ProviderOptions);
    options.ProviderOptions.ResponseType = "code";
    options.ProviderOptions.AdditionalProviderParameters.Add(
         "audience",  builder.Configuration["Auth0:Audience"]);
});

App.razor

<CascadingAuthenticationState>
    <Router AppAssembly="@typeof(App).Assembly">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
                <Authorizing>
                    <p>Determining session state, please wait...</p>
                </Authorizing>
                <NotAuthorized>
                    <h1>Sorry</h1>
                    <p>You are not authorized to view this page</p>
                </NotAuthorized>
            </AuthorizeRouteView>
            <FocusOnNavigate RouteData="@routeData" Selector="h1"/>
        </Found>
        <NotFound>
            <PageTitle>Not found</PageTitle>
            <LayoutView Layout="@typeof(MainLayout)">
                <p role="alert">Sorry, there's nothing at this address.</p>
            </LayoutView>
        </NotFound>
    </Router>
</CascadingAuthenticationState>

Solution

  • Source of issue

    The issue is caused by a timeout in the underlying implementation of the authentication services. I traced down the source, but there's no easy solution to this issue.

    If you enable Debug tracing for your WASM client, you should see this log message in the console:

    dbug: Microsoft.AspNetCore.Components.WebAssembly.Authentication.RemoteAuthenticationService[0] Initial silent sign in failed 'Frame window timed out'

    For me - using Keycloak (instead of Auth0), and Discord as IdP behind Keycloak - the Discord login cannot be framed in the hidden iframe:

    Refused to frame 'https://discord.com/' because it violates the following Content Security Policy directive: "frame-src 'self' my.domain.com".

    Of course this policy can be modified to include discord.com, but Discord denies being embedded that way with X-Frame-Options header.

    What's happening

    1. The app gets loaded
    2. AuthorizeViewCore is being rendered, entering OnParametersSetAsync:
      // Clear the previous result of authorization
      // This will cause the Authorizing state to be displayed until the authorization has been completed
      isAuthorized = null;
      
      currentAuthenticationState = await AuthenticationState;
      isAuthorized = await IsAuthorizedAsync(currentAuthenticationState.User);
      
    3. The AuthenticationState is initialized by RemoteAuthenticationService.GetAuthenticationStateAsync:
      new AuthenticationState(await GetUser(useCache: true));
      
    4. This will invoke GetAuthenticatedUser:
      /// <summary>
      /// Gets the current authenticated used using JavaScript interop.
      /// </summary>
      /// <returns>A <see cref="Task{ClaimsPrincipal}"/>that will return the current authenticated user when completes.</returns>
      protected internal virtual async ValueTask<ClaimsPrincipal> GetAuthenticatedUser()
      {
          await EnsureAuthService();
          var account = await JsRuntime.InvokeAsync<TAccount>("AuthenticationService.getUser");
          var user = await AccountClaimsPrincipalFactory.CreateUserAsync(account, Options.UserOptions);
      
          return user;
      }
      
    5. AuthenticationService.getUser will invoke trySilentSignIn:
          async trySilentSignIn() {
          if (!this._intialSilentSignIn) {
              this._intialSilentSignIn = (async () => {
                  try {
                      this.debug('Beginning initial silent sign in.');
                      await this._userManager.signinSilent();
                      this.debug('Initial silent sign in succeeded.');
                  } catch (e) {
                      if (e instanceof Error) {
                          this.debug(`Initial silent sign in failed '${e.message}'`);
                      }
                      // It is ok to swallow the exception here.
                      // The user might not be logged in and in that case it
                      // is expected for signinSilent to fail and throw
                  }
              })();
          }
      
          return this._intialSilentSignIn;
      }
      
    6. The await this._userManager.signinSilent(); will invoke the oidc-client-js UserManager signinSilent and then _signinSilentIframe:
      _signinSilentIframe(args = {}) {
          let url = args.redirect_uri || this.settings.silent_redirect_uri || this.settings.redirect_uri;
          if (!url) {
              Log.error("UserManager.signinSilent: No silent_redirect_uri configured");
              return Promise.reject(new Error("No silent_redirect_uri configured"));
          }
      
          args.redirect_uri = url;
          args.prompt = args.prompt || "none";
      
          return this._signin(args, this._iframeNavigator, {
              startUrl: url,
              silentRequestTimeout: args.silentRequestTimeout || this.settings.silentRequestTimeout
          }).then(user => {
              if (user) {
                  if (user.profile && user.profile.sub) {
                      Log.info("UserManager.signinSilent: successful, signed in sub: ", user.profile.sub);
                  }
                  else {
                      Log.info("UserManager.signinSilent: no sub");
                  }
              }
      
              return user;
          });
      }
      
    7. Finally, this will end up at IFrameWindow.js, which has a timeout of 10000 ms configured:
      const DefaultTimeout = 10000;
      
    8. The initially logged timeout error is thrown:
      _timeout() {
          Log.debug("IFrameWindow.timeout");
          this._error("Frame window timed out");
      }