Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

OidcClient2 - Closing IBrowser while waiting for LoginAsync

Currently I am developing a Xamarin App which is using IdentityModel.OidcClient to authenticate against my server, and it is being done using the automatic mode presented on the documentation (https://github.com/IdentityModel/IdentityModel.OidcClient2). Everything is working just fine as var result = await client.LoginAsync(); is returning the LoginResult with the AccessToken, etc.

What I am trying to figure out is how the backbutton, the recent apps button (both on android) and the close button on ChromeCustomTabsBrowser should be handled since these three actions close the Ibrowser attached to the oidcClient without returning a response and will keep me stuck awaiting for a response preventing me to process with the rest of the code validations.

   private async Task SignInAsync() {
        IsBusy = true;

        await Task.Delay(500);

        try {
            LoginResult result = await IdentityService.LoginAsync(new LoginRequest());

            if (result == null) {
                OnError(noInternetErrorMessage);
                IsBusy = false;
                return;
            }

            if (result.IsError) {
                OnError(result.Error);
            } else {
                string userName = result.User.Claims.Where(claim => claim.Type == userNameClaimType).Select(claim => claim.Value).SingleOrDefault();
                _UserToken = IdentityService.CreateOrUpdateUserToken(userName, result);

                if (_UserToken != null) {
                    await NavigationService.NavigateToAsync<LockScreenViewModel>();
                } else {
                    OnError(errorMessage);
                }
            }
        } catch (Exception e) {
            OnError(e.ToString());
        }

        IsBusy = false;
    }

In the previous block of code I can't reach if (result == null) if those buttons where clicked which in turn will prevent me from removing the ActivityIndicator in the loginView and provide the login button to the user so he can try login again.

like image 289
RMSPereira Avatar asked Mar 07 '23 00:03

RMSPereira


1 Answers

This happens because your IdentityService.LoginAsync() task is actually still waiting in the background for the custom tabs activity callback to happen, regardless of the fact that the custom tabs browser is no longer visible. Because the user closed before completing the login roundtrip, no callback will be triggered until the user completes the roundtrip in a future attempt. Each login attempt will create a new awaiting task, so the collection of waiting tasks will grow each time the user closes the custom tabs window prematurely.

At the time the user actually finishes a login roundtrip it becomes clear that the tasks are all still waiting, because they all at once unfreeze when the long awaited callback finally occurs. This poses another issue to handle, because all but the last task will result in an 'invalid state' oidc error result.

I resolved this by canceling the previous task just before starting a new login attempt. I added a TryCancel method to ChromeCustomTabsBrowser on a custom interface IBrowserExtra. In the ChromeCustomTabsBrowser.InvokeAsync implementation, a reference is kept to the TaskCompletionSource to be returned. The next time the user clicks the sign in button, TryCancel is first invoked before ChromeCustomTabsBrowser.LoginAsync to unlock the previous login attempt still awaiting, using the kept reference.

To make this work, IsBusy=True should be postponed until after the custom tabs callback (custom tabs browser will be on top anyway), to keep the gui interactive in case the custom tabs close button was clicked. Otherwise the user will never be able to reattempt login.

Update: added sample code as requested.

public interface IBrowserExtra
{
    void TryCancel();
}

public class ChromeCustomTabsBrowser : IBrowser, IBrowserExtra, IBrowserFallback
{
    private readonly Activity _context;
    private readonly CustomTabsActivityManager _manager;
    private TaskCompletionSource<BrowserResult> _task; 
    private Action<string> _callback;

    public ChromeCustomTabsBrowser()
    {
        _context = CrossCurrentActivity.Current.Activity;
        _manager = new CustomTabsActivityManager(_context);
    }

    public Task<BrowserResult> InvokeAsync(BrowserOptions options)
    {
        var builder = new CustomTabsIntent.Builder(_manager.Session)
            .SetToolbarColor(Color.Argb(255, 0, 0, 0))
            .SetShowTitle(false)
            .EnableUrlBarHiding()
            .SetStartAnimations(_context, Android.Resource.Animation.SlideInLeft, Android.Resource.Animation.SlideOutRight)
            .SetExitAnimations(_context, Android.Resource.Animation.SlideInLeft, Android.Resource.Animation.SlideOutRight);
        var customTabsIntent = builder.Build();

        // ensures the intent is not kept in the history stack, which makes
        // sure navigating away from it will close it
        customTabsIntent.Intent.AddFlags(ActivityFlags.NoHistory);

        _callback = null;
        _callback = url =>
        {
            UnsubscribeFromCallback();

            _task.TrySetResult(new BrowserResult()
            {
                Response = url
            });
        };

        SubscribeToCallback();

        // Keep track of this task to be able to refer it from TryCancel later 
        _task = new TaskCompletionSource<BrowserResult>();

        customTabsIntent.LaunchUrl(_context, Android.Net.Uri.Parse(options.StartUrl));

        return _task.Task;
    }

    private void SubscribeToCallback()
    {
        OidcCallbackActivity.Callbacks += _callback;
    }

    private void UnsubscribeFromCallback()
    {
        OidcCallbackActivity.Callbacks -= _callback;
        _callback = null;
    }

    void IBrowserExtra.TryCancel()
    {
        if (_callback != null)
        {
            UnsubscribeFromCallback();
        }

        if (_task != null)
        {
            _task.TrySetCanceled();
            _task = null;
        }
    }
}

public class LoginService
{
    private static OidcClient s_loginClient;
    private Task<LoginResult> _loginChallengeTask;

    private readonly IBrowser _browser;
    private readonly IAppInfo _appInfo;

    public LoginService(
        IBrowser secureBrowser,
        IBrowserFallback fallbackBrowser,
        IAppInfo appInfo)
    {
        _appInfo = appInfo;

        _browser = ChooseBrowser(appInfo, secureBrowser, fallbackBrowser);
    }

    private IBrowser ChooseBrowser(IAppInfo appInfo, IBrowser secureBrowser, IBrowserFallback fallbackBrowser)
    {
        return appInfo.PlatformSupportsSecureBrowserSession ? secureBrowser : fallbackBrowser as IBrowser;
    }

    public async Task<bool> StartLoginChallenge()
    {
        // Cancel any pending browser invocation task
        EnsureNoLoginChallengeActive();

        s_loginClient = OpenIdConnect.CreateOidcClient(_browser, _appInfo);

        try
        {
            _loginChallengeTask = s_loginClient.LoginAsync(new LoginRequest()
            {
                FrontChannelExtraParameters = OpenIdConnectConfiguration.LoginExtraParams
            });

            // This triggers the custom tabs browser login session
            var oidcResult = await _loginChallengeTask;

            if (_loginChallengeTask.IsCanceled)
            {
                // task can be cancelled if a second login attempt was completed while the first 
                // was cancelled prematurely even before the browser view appeared.
                return false;
            }
            else
            {
                // at this point we returned from the browser login session
                if (oidcResult?.IsError ?? throw new LoginException("oidcResult is null."))
                {
                    if (oidcResult.Error == "UserCancel")
                    {
                        // Graceful exit: user canceled using the close button on the browser view.
                        return false;
                    }
                    else
                    {
                        throw new LoginException(oidcResult.Error);
                    }
                }
            }

            // we get here if browser session just popped and navigation is back at customer page
            PerformPostLoginOperations(oidcResult);

            return true;
        }
        catch (TaskCanceledException)
        {
            // swallow cancel exception.
            // this can occur when user canceled browser session and restarted. 
            // Previous session is forcefully canceled at start of ExecuteLoginChallenge cauing this exception.
            LogHelper.Debug($"'Login attempt was via browser roundtrip canceled.");
            return false;
        }
    }

    private void EnsureNoLoginChallengeActive()
    {
        if (IsLoginSessionStarted)
        {
            (_browser as IBrowserExtra)?.TryCancel();
        }
    }

    private static bool IsLoginSessionStarted => s_loginClient != null;
}
like image 123
rinkeb Avatar answered Mar 21 '23 19:03

rinkeb