Search code examples
xamarin.formsoauthxamarin.androiddropboxpkce

Accessing Dropbox from Xamarin Forms using PKCE OAuth and .NET API - solution


Implementing Dropbox support in Xamarin Forms was, let’s say, interesting, especially using the more secure PKCE OAuth flow, which requires deep linking, as WebView is insecure.

For anyone struggling as much as I was, working code is shown below, including shared code and Android code. I haven’t needed to implement the iOS side as I’m using iCloud rather than Dropbox there, but that should be straightforward.

You may want to add an ActivityIndicator to the calling page, as it pops in and out of view during authorization.

Note: While the Dropbox .NET API is not officially supported for Xamarin, it can be made to work, as shown here.

EDIT 18 Sep 2021: Added code to (1) handle case where user declines to accept access to Dropbox and (2) close the browser after authorization. A remaining issue: each time we authorize, a tab gets added to the browser - don't see how to overcome that.


Solution

  • ANDROID CODE

    using System;
    using System.Net;
    using System.Threading.Tasks;
    
    using Xamarin.Forms;
    
    using Android.Content;
    using Android.App;
    
    using Plugin.CurrentActivity;
    using MyApp.Droid.DropboxAuth;
    using AndroidX.Activity;
    
    [assembly: Dependency (typeof (DropboxOAuth2_Android))]
    
    namespace MyApp.Droid.DropboxAuth
    {
        public class DropboxOAuth2_Android: Activity, IDropbox
        {
            public bool IsBrowserInstalled ()
            // Returns true if a web browser is installed
            {
                string url = "https://google.com";      // Any url will do
                Android.Net.Uri webAddress = Android.Net.Uri.Parse ( url );
                Intent intentWeb = new Intent ( Intent.ActionView, webAddress );
                Context currentContext = CrossCurrentActivity.Current.Activity;
                Android.Content.PM.PackageManager packageManager = currentContext.PackageManager;
                return intentWeb.ResolveActivity ( packageManager ) != null;
            }
    
            public void OpenBrowser ( string url )
            // Opens default browser
            {
                Intent intent = new Intent ( Intent.ActionView, Android.Net.Uri.Parse ( url ) );
                Context currentContext = CrossCurrentActivity.Current.Activity;
                currentContext.StartActivity ( intent );
            }
    
            public void CloseBrowser ()
            // Close the browser
            {
                Finish ();
            }
        }
    }
    
    using System;
    
    using Android.App;
    using Android.Content;
    using Android.OS;
    using Android.Content.PM;
    
    using MyApp.DropboxService;
    
    namespace MyApp.Droid.DropboxAuth
    {
        public class Redirection_Android
        {
            [Activity(NoHistory = true, LaunchMode = LaunchMode.SingleTop)]
            [IntentFilter ( new [] { Intent.ActionView },
                  Categories = new[] { Intent.CategoryBrowsable, Intent.CategoryDefault },
                  DataScheme = "com.mydomain.myapp" )]
            public class RedirectHandler : Activity
            {
                protected async override void OnCreate ( Bundle savedInstanceState )
                {
                    base.OnCreate( savedInstanceState );
                    Intent intent = Intent;                             // The intent that started this activity
                    
                    if ( Intent.Action == Intent.ActionView )
                    {
                        Android.Net.Uri uri = intent.Data;
    
                        if ( uri.ToString ().Contains ("The+user+chose+not+to+give+your+app+access" ) )
                        {
                            // User pressed Cancel not Accept
                            if ( MyApp.DropboxService.Authorization.Semaphore != null )
                            {
                                // Release semaphore
                                Behayve.DropboxService.Authorization.Semaphore.Release ();
                                Behayve.DropboxService.Authorization.Semaphore.Dispose ();
                                Behayve.DropboxService.Authorization.Semaphore = null;  
                            }
    
                            Xamarin.Forms.DependencyService.Get<IDropbox> ().CloseBrowser ();
    
                            Finish ();
                            return;
                        }
    
                        if ( uri.GetQueryParameter ( "state" ) != null )
                        { 
                            // Protect from curious eyes
                            if ( uri.GetQueryParameter ( "state" ) != Authorization.StatePKCE )
                                Finish ();
    
                            if ( uri.GetQueryParameter ( "code" ) != null )
                            {
                                string code = uri.GetQueryParameter ( "code" );                       
    
                                // Perform stage 2 flow, storing tokens in settings
                                bool success = await Authorization.Stage2FlowAsync ( code );
                                Authorization.IsAuthorizationComplete = true;
    
                                // Allow shared code that initiated this activity to continue
                                Authorization.Semaphore.Release ();
                            }
                        }
                    }
    
                    Finish ();
                }
            }
        }
    }
    

    NOTE: If targeting API 30 or later, add the following to your manifest within the <queries> tag:

    <intent>
            <action android:name="android.intent.action.VIEW" />
            <category android:name="android.intent.category.BROWSABLE" />
            <data android:scheme="https" />
    </intent>
    

    SHARED CODE

    using System;
    
    namespace MyApp
    {
        public interface IDropbox
    
        {
            bool IsBrowserInstalled ();                 // True if a browser is installed
            void OpenBrowser ( string url );            // Opens url in internal browser
            void CloseBrowser ();                       // Closes the browser
        }
    }
    
    using System;
    using System.Threading;
    using System.Threading.Tasks;
    using System.Net.Http;
    
    using Xamarin.Forms;
    
    using MyApp.Resx;
    
    using Dropbox.Api;
    
    namespace MyApp.DropboxService
    {
        public class Authorization
        {
            private const string packageName = "com.mydomain.myapp";     // Copied from Android manifest
            private const string redirectUri = packageName + ":/oauth2redirect";
            private static PKCEOAuthFlow pkce;
            private const string clientId = “abcabcabcabcabc”;      // From Dropbox app console
            private static DropboxClientConfig dropboxClientConfig;
    
            // Settings keys
            private const string accessTokenKey = "accessTokenKey";
            public const string refreshTokenKey = "refreshTokenKey";
            private const string userIdKey = "userIdKey";
    
            public static string StatePKCE {get; private set; }
            public static SemaphoreSlim Semaphore { get; set; }             // Allows shared code to wait for redirect-triggered Android activity to complete
            public static volatile bool IsAuthorizationComplete;            // Authorization is complete, tokens stored in settings
    
            public Authorization ()
            {
                IsAuthorizationComplete = false;
                Semaphore = new SemaphoreSlim ( 1,1 );
            }
    
            public async Task<DropboxClient> GetAuthorizedDropBoxClientAsync ()
            // If access tokens not already stored in secure settings, first verifies a browser is installed,
            // then after a browser-based user authorisation dialog, securely stores access token, refresh token and user ID in settings.
            // Returns a long-lived authorised DropboxClient (based on a refresh token stored in settings).
            // Returns null if not authorised or no browser or if user hit Cancel or Back (no token stored).
            // Operations can then be performed on user's Dropbox over time via the DropboxClient. 
            //
            // Assumes caller has verified Internet is available.
            //
            // Employs the PKCE OAuth flow.
            // WebView is not used because of associated security issues -- deep linking is used instead.
            // The tokens can be retrieved from settings any time should they be desired.
            // No auxiliary website is used.
            {
                if ( string.IsNullOrEmpty ( await Utility.GetSettingAsync ( refreshTokenKey ) ) )
                {
                    // We do not yet have a refresh key
                    try
                    {
                        // Verify user has a suitable browser installed
                        if ( ! DependencyService.Get<IDropbox> ().IsBrowserInstalled () )
                        {
                            await App.NavPage.DisplayAlert ( T.NoBrowserInstalled, T.InstallBrowser, T.ButtonOK );
                            return null;
                        }
    
                        // Stage 1 flow
                        IsAuthorizationComplete = false;
                        DropboxCertHelper.InitializeCertPinning ();
                        pkce = new PKCEOAuthFlow ();                // Generates code verifier and code challenge for PKCE
                        StatePKCE = Guid.NewGuid ().ToString ( "N" );
                        // NOTE: Here authorizeRedirectUI is of the form com.mydomain.myapp:/oauth2redirect
                        Uri authorizeUri = pkce.GetAuthorizeUri ( OAuthResponseType.Code, clientId: clientId, redirectUri:redirectUri,
                                                         state: StatePKCE, tokenAccessType: TokenAccessType.Offline, scopeList: null, includeGrantedScopes: IncludeGrantedScopes.None );
    
                        // NOTE: authorizeUri looks like this:
                        // https://www.dropbox.com/oauth2/authorize?response_type=code&client_id=abcabcabcabcabc&redirect_uri=com.mydomain.myapp%3A%2Foauth2redirect&state=51cbbd2b7bce4d7990bc72fc95991375&token_access_type=offline&code_challenge_method=S256&code_challenge=r75HUStz-F43vWl2yr9m5ctgF1lgE7uqu-cf_gQpSEU                   
    
                        // Open authorization url in browser
                        await Semaphore.WaitAsync ();                               // Take semaphore
                        DependencyService.Get<IDropbox> ().OpenBrowser ( authorizeUri.AbsoluteUri );
                       
                        // Wait until Android redirection activity obtains tokens and releases semaphore
                        // NOTE: User might first press Cancel or Back button - this returns user to page calling this method, where OnAppearing will run
                        await Semaphore.WaitAsync ();
                    }
                    catch
                    {
                        if ( Semaphore != null )
                            Semaphore.Dispose ();
                        return null;
                    }
                }
                else
                    IsAuthorizationComplete = true;
    
                // Wrap up
    
                if ( Semaphore != null )
                    Semaphore.Dispose ();
    
                if ( IsAuthorizationComplete )
                {
                    // Return authorised Dropbox client
                    DropboxClient dropboxClient = await AuthorizedDropboxClientAsync ();
    
                    DependencyService.Get<IDropbox> ().CloseBrowser ();
                    return dropboxClient;
                }
    
                return null;
            }
    
            public static async Task<bool> Stage2FlowAsync ( string code )
            // Obtains authorization token, refresh token and user Id, and
            // stores them in settings.
            // code = authorization code obtained in stage 1 flow
            // Returns true if tokens obtained
            {
                // Retrieve tokens
                OAuth2Response response = await pkce.ProcessCodeFlowAsync ( code, clientId, redirectUri: redirectUri );
                if ( response == null )
                    return false;
    
                string accessToken = response.AccessToken;
                string refreshToken = response.RefreshToken;
                string userId = response.Uid;
    
                // Save tokens in settings
                await Utility.SetSettingAsync ( accessTokenKey, accessToken );
                await Utility.SetSettingAsync ( refreshTokenKey, refreshToken );
                await Utility.SetSettingAsync ( userIdKey, userId );
    
                return true;
            }
    
            public static async Task<DropboxClient> AuthorizedDropboxClientAsync ( )
            // Returns authorized Dropbox client, or null if none available
            // For use when Dropbox authorization has already taken place
            {
                string refreshToken = await Utility.GetSettingAsync ( Authorization.refreshTokenKey );
                // NOTE: Due to Dropbox.NET API bug for Xamarin, we need to override Android Build HttpClientImplementation setting (AndroidClientHandler) with HTTPClientHandler, for downloads to work
                dropboxClientConfig = new DropboxClientConfig () { HttpClient = new HttpClient ( new HttpClientHandler () ) };
                return new DropboxClient ( refreshToken, clientId, dropboxClientConfig );
            }
    
            public static async Task ClearTokensInSettingsAsync ()
            // Clears access token, refresh token, user Id token
            // Called when app initialises
            {
                await Utility.SetSettingAsync ( accessTokenKey, string.Empty );
                await Utility.SetSettingAsync ( refreshTokenKey, string.Empty );
                await Utility.SetSettingAsync ( userIdKey, string.Empty );
            }
    
            public static async Task<bool> IsLoggedInAsync ()
            // Returns true if logged in to Dropbox
            {
                if ( await Utility.GetSettingAsync ( refreshTokenKey ) == string.Empty )
                    return false;
                return true;
            }
        }
    }
    
    using System;
    using System.Threading.Tasks;
    using System.Collections.Generic;
    using System.IO;
    using System.Text;
    
    using Dropbox.Api;
    using Dropbox.Api.Files;
    
    using MyApp.Resx;
    
    namespace MyApp.DropboxService
    {
        public class FileHelper
        {
        const string _FNF = “~FNF”;
    
            public static async Task<bool> ExistsAsync ( DropboxClient dbx, string path )
            // Returns true if given filepath/folderpath exists for given Dropbox client
            // Dropbox requires "/" to be the initial character
            { 
                try
                {
                    GetMetadataArg getMetadataArg = new GetMetadataArg ( path );
                    Metadata xx = await dbx.Files.GetMetadataAsync ( getMetadataArg );
                }
                catch ( Exception ex )
                {
                    if ( ex.Message.Contains ( "not_found" ) )      // Seems no other way to do it
                    return false;
    
                    await Utility.WriteLogFileAsync ( T.Exception, ex.AsString () );
                    throw new Exception ( "In FileHelper.ExistsAsync " + ex.ToString (), ex.InnerException );
                }
    
                return true;
            }
    
            public static async Task<CreateFolderResult> CreateFolderAsync ( DropboxClient dbx, string path )
            // Creates folder for given Dropbox user at given path, unless it already exists
            // Returns CreateFolderResult, or null if already exists
            {
                try
                {
                    if ( await ExistsAsync ( dbx, path ) )
                        return null;
    
                    CreateFolderArg folderArg = new CreateFolderArg( path );
                    return await dbx.Files.CreateFolderV2Async( folderArg );
                }
                catch ( Exception ex )
                {
                    await Utility.WriteLogFileAsync ( T.Exception, ex.AsString () );
                    throw new Exception ( "In FileHelper.CreateFolderAsync " + ex.ToString (), ex.InnerException );
                }  
            }
    
            public static async Task DeleteFileAsync ( DropboxClient dbx, string path )
            // Delete given Dropbox user's given file
            {
                try
                {
                    DeleteArg deleteArg = new DeleteArg ( path );
                    await dbx.Files.DeleteV2Async ( deleteArg );
                }
                catch ( Exception ex )
                {
                    await Utility.WriteLogFileAsync ( T.Exception, ex.AsString () );
                    throw new Exception  ( "In FileHelper.DeleteFileAsync " + ex.ToString (), ex.InnerException );
                }
            }
    
            public static async Task<FileMetadata> UploadBinaryFileAsync ( DropboxClient dbx, string localFilepath, string dropboxFilepath )
            // Copies given local binary file to given Dropbox file, deleting any pre-existing destination file
            // NOTE: Dropbox requires initial "/" in dropboxFilePath
            {
                int tries = 0;
    
                while ( tries < 30 )
                {
                    try
                    {
                        if ( await ExistsAsync ( dbx, dropboxFilepath ) )
                            await DeleteFileAsync ( dbx, dropboxFilepath );
    
                        using ( FileStream localStream = new FileStream ( localFilepath, FileMode.Open, FileAccess.Read ) )
                        {
                            return await dbx.Files.UploadAsync ( dropboxFilepath,
                                                                 WriteMode.Overwrite.Instance,
                                                                 body: localStream );                         
                        }
                    }
                    catch ( RateLimitException ex )
                    {
                        // We have to back off and retry later
                        int backoffSeconds= ex.RetryAfter;      // >= 0
                        System.Diagnostics.Debug.WriteLine ( "****** Dropbox requested backoff of " + backoffSeconds.ToString () + " seconds" );
                        await Task.Delay ( backoffSeconds * 1000 );
                    }
                    catch ( Exception ex )
                    {
                        await Utility.WriteLogFileAsync ( T.Exception, ex.AsString () );
                        throw new Exception ( "In FileHelper.UploadBinaryFileAsync " + ex.ToString (), ex.InnerException );
                    }
                    tries++;
                }
                return null;
            }
    
            public static async Task<FileMetadata> UploadTextFileAsync ( DropboxClient dbx, string localFilepath, string dropboxFilepath )
            // Copies given local text file to given Dropbox file, deleting any pre-existing destination file
            {
                int tries = 0;
    
                while ( tries < 30 )
                { 
                    try
                    {
                        if ( await ExistsAsync ( dbx, dropboxFilepath ) )
                            await DeleteFileAsync ( dbx, dropboxFilepath );
    
                        string fileContents = File.ReadAllText ( localFilepath );
                        using ( MemoryStream localStream = new MemoryStream ( Encoding.UTF8.GetBytes ( fileContents ) ) )
                        {
                            return await dbx.Files.UploadAsync ( dropboxFilepath,
                                                                 WriteMode.Overwrite.Instance,
                                                                 body: localStream );
                        }
                    }
                    catch ( RateLimitException ex )
                    {
                        // We have to back off and retry later
                        int backoffSeconds= ex.RetryAfter;      // >= 0
                        System.Diagnostics.Debug.WriteLine ( "****** Dropbox requested backoff of " + backoffSeconds.ToString () + " seconds" );
                        await Task.Delay ( backoffSeconds * 1000 );
                    }
                    catch ( Exception ex )
                    {
                        await Utility.WriteLogFileAsync ( T.Exception, ex.AsString () );
                        throw new Exception ( "In FileHelper.UploadTextFileAsync " + ex.ToString (), ex.InnerException );
                    }
                    tries++;
                }
                return null;
            }
    
            public static async Task<bool> DownloadFileAsync ( DropboxClient dbx, string dropboxFilepath, string localFilepath )
            // Copies given Dropbox file to given local file, deleting any pre-existing destination file
            // Returns true if successful
            // NOTE: Dropbox requires initial "/" in dropboxFilePath
            {
                int tries = 0;
    
                while ( tries < 30 )
                {
                    try
                    {
                        // If destination exists, delete it
                        if ( File.Exists ( localFilepath ) )
                            File.Delete ( localFilepath );
    
                        // Copy file
                        using ( var response = await dbx.Files.DownloadAsync ( dropboxFilepath ) )
                        {
                            using ( FileStream fileStream = File.Create ( localFilepath ) )
                            {
                                ( await response.GetContentAsStreamAsync() ).CopyTo ( fileStream );
                            }
                        }
                        return true;
                    }
                    catch ( RateLimitException ex )
                    {
                        // We have to back off and retry later
                        int backoffSeconds= ex.RetryAfter;      // >= 0
                        System.Diagnostics.Debug.WriteLine ( "****** Dropbox requested backoff of " + backoffSeconds.ToString () + " seconds" );
                        await Task.Delay ( backoffSeconds * 1000 );
                    }
                    catch ( Exception ex )
                    {
                        await Utility.WriteLogFileAsync ( T.Exception, ex.AsString () );
                    }
                    tries++;
                }
                return false;
            }
    
            public static async Task EnsureSubfolderExistsAsync ( DropboxClient dbx, string subfolderPath )
            // Creates given subfolder for given client unless it already exists
            {
                if ( await ExistsAsync ( dbx, subfolderPath ) )
                    return;
    
                await CreateFolderAsync ( dbx, subfolderPath);
            }
        }
    }
    
    using Xamarin.Forms;
    using Xamarin.Essentials;
    
    namespace MyApp
    {
        public class Utility
        {
    
    
            public static async Task SetSettingAsync ( string key, string settingValue )
            // Stores given value in setting whose key is given
            // Uses secure storage if possible, otherwise uses preferences
            {
                try
                {
                    await SecureStorage.SetAsync ( key, settingValue );
                }
                catch
                {
                    // On some Android devices, secure storage is not supported - here if that is the case
                    // Use preferences
                    Preferences.Set ( key, settingValue );
                }
            }
    
            public static async Task<string> GetSettingAsync ( string key )
            // Returns setting with given name, or null if unavailable
            // Uses secure storage if possible, otherwise uses preferences
            {
                string settingValue;
    
                try
                {
                    settingValue = await SecureStorage.GetAsync ( key );
                }
                catch
                {
                    // Secure storage is unavailable on this device so use preferences
                    settingValue = Preferences.Get ( key, defaultValue: null );
                }
    
                return settingValue;
            }
    

    In the Dropbox app console, permission type is Scoped App (App Folder), and permissions are files.content.write and files.content.read.