Skip to content

Silent sign-in with oidc-client.js causes timeout issues with some IdPs #60115

Open
@danroth27

Description

@danroth27

Original posted by @Herdo:

#40764 (comment)

The current implementation with iframes and silent signin based on oidc-client-js is causing some timeout issues with third-party IdPs. I've described the cause of this issue in this Stackoverflow question: Blazor WASM - Spending a long time initially in Authorizing component. I'll add my analysis results below.

IMHO, the new solution should allow more freedom for cases like this, where you cannot influence IdP configuration like X-Frame-Options header.

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");
    }

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-blazorIncludes: Blazor, Razor Components

    Type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions