DEV Community

Angelo Diamante
Angelo Diamante

Posted on

.NET MAUI Google Drive OAuth on Windows and Android

Google Cloud Console

  • Create project if it doesn't exist yet
  • Select your project

Select/New project

  • Add Google Drive API

APIs & Services link

Enable APIs & Services link

Search google drive API

  • Set up OAuth consent screen

OAuth consent screen link

  • Make sure to add your test user

Add test user

  • Create Credentials

Create Credentials link

  • Windows OAuth client ID
    • choose Universal Windows Platform (UWP)
    • set Store ID to test. Will need to be different for a real app

Windows OAuth Client ID

  • Android OAuth client ID
    • choose Android
    • set package name to the same as project app identifier
    • Set SHA-1 certificate fingerprint to 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00. For a real app you will need to create to your own. For Windows, you can install Java that will have keytool in a bin folder that you can use.
    • Enabled custom URI scheme

Android OAuth Client ID

.NET MAUI Project

Start from a new project

Install the following NuGet packages

  • Google.Apis.Auth
  • Google.Apis.Drive.v3
  • Google.Apis.Oauth2.v2

Set up Android

Add the file WebAuthenticatorCallbackActivity.cs to Platform/Android folder with the following content:

using Android.App;
using Android.Content;
using Android.Content.PM;

namespace OAuthSample.Platforms.Android;

[Activity(NoHistory = true, LaunchMode = LaunchMode.SingleTop, Exported = true)]
[IntentFilter(new[] { Intent.ActionView },
              Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable },
              DataScheme = CALLBACK_SCHEME)]
public class WebAuthenticationCallbackActivity : Microsoft.Maui.Authentication.WebAuthenticatorCallbackActivity
{

    const string CALLBACK_SCHEME = "com.companyname.oauthsample";
}
Enter fullscreen mode Exit fullscreen mode

Set up GoogleDrive Service

Add the file GoogleDriveService.cs to Services folder with the following content (set your UWP and android client id at the top):

using Google.Apis.Auth.OAuth2;
using Google.Apis.Drive.v3;
using Google.Apis.Oauth2.v2;
using Google.Apis.Services;
using System.Diagnostics;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;

namespace OAuthSample.Services;

public class GoogleDriveService
{
    readonly string _windowsClientId = "__UWP_CLIENT_ID_HERE__";      // UWP client
    readonly string _androidClientId = "__ANDROID_CLIENT_ID_HERE__";  // Android client

    Oauth2Service? _oauth2Service;
    DriveService? _driveService;
    GoogleCredential? _credential;
    string? _email;

    public bool IsSignedIn => _credential != null;
    public string? Email => _email;

    public async Task Init()
    {
        var hasRefreshToken = await SecureStorage.GetAsync("refresh_token") is not null;
        if (!IsSignedIn && hasRefreshToken)
        {
            await SignIn();
        }
    }

    public async Task SignIn()
    {
        var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        var expiresIn = Preferences.Get("access_token_epires_in", 0L);
        var isExpired = now - 10 > expiresIn;   // 10 second buffer
        var hasRefreshToken = await SecureStorage.GetAsync("refresh_token") is not null;

        if (isExpired && hasRefreshToken)
        {
            Debug.WriteLine("Using refresh token");
            await RefreshToken();
        }
        else if (isExpired)     // No refresh token
        {
            Debug.WriteLine("Starting auth code flow");
            if (DeviceInfo.Current.Platform == DevicePlatform.WinUI)
            {
                await DoAuthCodeFlowWindows();
            }
            else if (DeviceInfo.Current.Platform == DevicePlatform.Android)
            {
                await DoAuthCodeFlowAndroid();
            }
            else
            {
                throw new NotImplementedException($"Auth flow for platform {DeviceInfo.Current.Platform} not implemented.");
            }
        }

        var accesToken = await SecureStorage.GetAsync("access_token");
        _credential = GoogleCredential.FromAccessToken(accesToken);
        _oauth2Service = new Oauth2Service(new BaseClientService.Initializer
        {
            HttpClientInitializer = _credential,
            ApplicationName = "yeetmedia3"
        });
        _driveService = new DriveService(new BaseClientService.Initializer
        {
            HttpClientInitializer = _credential,
            ApplicationName = "yeetmedia3"
        });
        var userInfo = await _oauth2Service.Userinfo.Get().ExecuteAsync();
        _email = userInfo.Email;
    }

    public async Task<string> ListFiles()
    {
        var request = _driveService!.Files.List();
        var fileList = await request.ExecuteAsync();
        var stringBuilder = new StringBuilder();

        stringBuilder.AppendLine("Files:");
        stringBuilder.AppendLine();
        if (fileList.Files != null && fileList.Files.Count > 0)
        {
            foreach (var file in fileList.Files)
            {
                stringBuilder.AppendLine($"Files: {file.Name} ({file.Id}");
            }
        }
        else
        {
            stringBuilder.AppendLine("No files found.");
        }
        return stringBuilder.ToString();
    }

    public async Task SignOut()
    {
        await RevokeTokens();
    }

    private async Task DoAuthCodeFlowWindows()
    {
        var authUrl = "https://accounts.google.com/o/oauth2/v2/auth";
        var clientId = _windowsClientId;
        var localPort = 12345;
        var redirectUri = $"http://localhost:{localPort}";
        var codeVerifier = GenerateCodeVerifier();
        var codeChallenge = GenerateCodeChallenge(codeVerifier);
        var parameters = GenerateAuthParameters(redirectUri, clientId, codeChallenge);
        var queryString = string.Join("&", parameters.Select(param => $"{param.Key}={param.Value}"));
        var fullAuthUrl = $"{authUrl}?{queryString}";

        await Launcher.OpenAsync(fullAuthUrl);
        var authorizationCode = await StartLocalHttpServerAsync(localPort);

        await GetInitialToken(authorizationCode, redirectUri, clientId, codeVerifier);
    }

    private async Task DoAuthCodeFlowAndroid()
    {
        var authUrl = "https://accounts.google.com/o/oauth2/v2/auth";
        var clientId = _androidClientId;
        var redirectUri = "com.companyname.yeetmedia3://";  // requires a period: https://developers.google.com/identity/protocols/oauth2/native-app#android
        var codeVerifier = GenerateCodeVerifier();
        var codeChallenge = GenerateCodeChallenge(codeVerifier);
        var parameters = GenerateAuthParameters(redirectUri, clientId, codeChallenge);
        var queryString = string.Join("&", parameters.Select(param => $"{param.Key}={param.Value}"));
        var fullAuthUrl = $"{authUrl}?{queryString}";
#pragma warning disable CA1416
        var authCodeResponse = await WebAuthenticator.AuthenticateAsync(new Uri(fullAuthUrl), new Uri("com.companyname.yeetmedia3://"));
#pragma warning restore CA1416
        var authorizationCode = authCodeResponse.Properties["code"];

        await GetInitialToken(authorizationCode, redirectUri, clientId, codeVerifier);
    }

    private static Dictionary<string, string> GenerateAuthParameters(string redirectUri, string clientId, string codeChallenge)
    {
        return new Dictionary<string, string>
        {
            //{ "scope", "https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/drive.appdata" },
            { "scope", string.Join(' ', [Oauth2Service.Scope.UserinfoProfile, Oauth2Service.Scope.UserinfoEmail, DriveService.Scope.Drive, DriveService.Scope.DriveFile, DriveService.Scope.DriveAppdata]) },
            { "access_type", "offline" },
            { "include_granted_scopes", "true" },
            { "response_type", "code" },
            //{ "state", "state_parameter_passthrough_value" },
            { "redirect_uri", redirectUri },
            { "client_id", clientId },
            { "code_challenge_method", "S256" },
            { "code_challenge", codeChallenge },
            //{ "prompt", "consent" }
        };
    }

    private static async Task GetInitialToken(string authorizationCode, string redirectUri, string clientId, string codeVerifier)
    {
        var tokenEndpoint = "https://oauth2.googleapis.com/token";
        var client = new HttpClient();
        var tokenRequest = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint)
        {
            Content = new FormUrlEncodedContent(
            [
                new KeyValuePair<string, string>("grant_type", "authorization_code"),
                new KeyValuePair<string, string>("code", authorizationCode),
                new KeyValuePair<string, string>("redirect_uri", redirectUri),
                new KeyValuePair<string, string>("client_id", clientId),
                new KeyValuePair<string, string>("code_verifier", codeVerifier)
            ])
        };

        var response = await client.SendAsync(tokenRequest);
        var responseBody = await response.Content.ReadAsStringAsync();

        if (!response.IsSuccessStatusCode) throw new Exception($"Error requesting token: {responseBody}");

        Debug.WriteLine($"Access token: {responseBody}");
        var jsonToken = JsonObject.Parse(responseBody);
        var accessToken = jsonToken!["access_token"]!.ToString();
        var refreshToken = jsonToken!["refresh_token"]!.ToString();
        var accessTokenExpiresIn = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + int.Parse(jsonToken!["expires_in"]!.ToString());
        await SecureStorage.SetAsync("access_token", accessToken);
        await SecureStorage.SetAsync("refresh_token", refreshToken);
        Preferences.Set("access_token_epires_in", accessTokenExpiresIn);
    }

    private async Task RefreshToken()
    {
        var clientId = DeviceInfo.Current.Platform == DevicePlatform.WinUI ? _windowsClientId : _androidClientId;
        var tokenEndpoint = "https://oauth2.googleapis.com/token";
        var refreshToken = await SecureStorage.GetAsync("refresh_token");
        var client = new HttpClient();
        var tokenRequest = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint)
        {
            Content = new FormUrlEncodedContent(
                [
                    new KeyValuePair<string, string>("client_id", clientId),
                    new KeyValuePair<string, string>("grant_type", "refresh_token"),
                    new KeyValuePair<string, string>("refresh_token", refreshToken!)
                ]
            )
        };

        var response = await client.SendAsync(tokenRequest);
        var responseBody = await response.Content.ReadAsStringAsync();

        if (!response.IsSuccessStatusCode) throw new Exception($"Error requesting token: {responseBody}");

        Debug.WriteLine($"Refresh token: {responseBody}");
        var jsonToken = JsonObject.Parse(responseBody);
        var accessToken = jsonToken!["access_token"]!.ToString();
        var accessTokenExpiresIn = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + int.Parse(jsonToken!["expires_in"]!.ToString());
        await SecureStorage.SetAsync("access_token", accessToken);
        Preferences.Set("access_token_epires_in", accessTokenExpiresIn);
    }

    private async Task RevokeTokens()
    {
        var revokeEndpoint = "https://oauth2.googleapis.com/revoke";
        var access_token = await SecureStorage.GetAsync("access_token");
        var client = new HttpClient();
        var tokenRequest = new HttpRequestMessage(HttpMethod.Post, revokeEndpoint)
        {
            Content = new FormUrlEncodedContent(
                [
                    new KeyValuePair<string, string>("token", access_token!),
                ]
            )
        };

        var response = await client.SendAsync(tokenRequest);
        var responseBody = await response.Content.ReadAsStringAsync();

        if (!response.IsSuccessStatusCode) throw new Exception($"Error revoking token: {responseBody}");

        Debug.WriteLine($"Revoke token: {responseBody}");
        SecureStorage.Remove("access_token");
        SecureStorage.Remove("refresh_token");
        Preferences.Remove("access_token_epires_in");

        _credential = null;
        _oauth2Service = null;
        _driveService = null;
    }

    private static async Task<string> StartLocalHttpServerAsync(int port)
    {
        var listener = new HttpListener();
        listener.Prefixes.Add($"http://localhost:{port}/");
        listener.Start();

        Debug.WriteLine($"Listening on http://localhost:{port}/...");
        var context = await listener.GetContextAsync();

        var code = context.Request.QueryString["code"];
        var response = context.Response;
        var responseString = "Authorization complete. You can close this window.";
        var buffer = System.Text.Encoding.UTF8.GetBytes(responseString);
        response.ContentLength64 = buffer.Length;
        await response.OutputStream.WriteAsync(buffer);
        response.OutputStream.Close();

        listener.Stop();

        if (code is null) throw new Exception("Auth ode not returned");

        return code;
    }

    private static string GenerateCodeVerifier()
    {
        using var rng = RandomNumberGenerator.Create();
        var bytes = new byte[32]; // Length can vary, e.g., 43-128 characters
        rng.GetBytes(bytes);
        return Convert.ToBase64String(bytes)
            .TrimEnd('=')
            .Replace('+', '-')
            .Replace('/', '_');
    }

    private static string GenerateCodeChallenge(string codeVerifier)
    {
        var hash = SHA256.HashData(Encoding.ASCII.GetBytes(codeVerifier));
        return Convert.ToBase64String(hash)
            .TrimEnd('=')
            .Replace('+', '-')
            .Replace('/', '_');
    }
}
Enter fullscreen mode Exit fullscreen mode

Update MainPage.xaml to the following:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="OAuthSample.MainPage"
             Loaded="ContentPage_Loaded">
    <ScrollView>
        <VerticalStackLayout Padding="30,0" Spacing="25">
            <Button
                x:Name="SignInButton"
                Text="Sign In" 
                Clicked="SignIn_Clicked"
                HorizontalOptions="Fill" />
            <Button
                x:Name="ListButton"
                Text="List"
                Clicked="List_Clicked"
                HorizontalOptions="Fill"
                IsVisible="False" />
            <Label x:Name="ListLabel" />
        </VerticalStackLayout>
    </ScrollView>
</ContentPage>
Enter fullscreen mode Exit fullscreen mode

Update MainPage.xaml.cs to the following:

using OAuthSample.Services;

namespace OAuthSample;

public partial class MainPage : ContentPage
{
    readonly GoogleDriveService _googleDriveService = new();
    public MainPage()
    {
        InitializeComponent();
    }

    private async void ContentPage_Loaded(object sender, EventArgs e)
    {
        await _googleDriveService.Init();
        UpdateButton();
    }

    private async void SignIn_Clicked(object sender, EventArgs e)
    {
        if (SignInButton.Text == "Sign In")
        {
            await _googleDriveService.SignIn();
        }
        else
        {
            await _googleDriveService.SignOut();

        }
        UpdateButton();
    }

    private async void List_Clicked(object sender, EventArgs e)
    {
        ListLabel.Text = await _googleDriveService.ListFiles();
    }

    private void UpdateButton()
    {
        if (_googleDriveService.IsSignedIn)
        {
            SignInButton.Text = $"Sign Out ({_googleDriveService.Email})";
            ListButton.IsVisible = true;
        }
        else
        {
            SignInButton.Text = "Sign In";
            ListButton.IsVisible = false;
            ListLabel.Text = String.Empty;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

(Optional) Control window size for Windows. Update AppShell.xaml.cs to the following:

namespace OAuthSample;

public partial class App : Application
{
    public App()
    {
        InitializeComponent();
    }

    protected override Window CreateWindow(IActivationState? activationState)
    {
        var displayInfo = DeviceDisplay.Current.MainDisplayInfo;
        var width = 700;
        var height = 500;
        var centerX = (displayInfo.Width / displayInfo.Density - width) / 2;
        var centerY = (displayInfo.Height / displayInfo.Density - height) / 2;

        return new Window(new AppShell())
        {
            Width = width,
            Height = height,
            X = centerX,
            Y = centerY
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

Test on Windows

  • Run with Windows Machine profile

Windows Machine profile

  • Press Sign In

Windows sign in

  • Choose account in browser. Note: this account needs to be specified as a test user in OAuth consent screen Test user section (this restriction will be lifted once your app is published).

Choose account windows

Allow access windows

Authentication complete windows

  • Press List

Press list windows

  • See files below

See files windows

Test on Android

  • Run with an Android Emulator profile

Android profile

  • Press Sign In

Android Sign in

  • Choose account in browser. Note: this account needs to be specified as a test user in OAuth consent screen Test user section (this restriction will be lifted once your app is published).

Choose account android

Allow access android

  • Press List

Press list android

  • See files below

See files android

Github sample app

https://github.com/adiamante/maui.oauth.sample

References

Google Identity documentation

How-To: OAuth2.0 Authentication in NET MAUI using Personal Cloud Providers

#7. OAuth 2.0 | Upload File In Google Drive By API Using Postman | Simple |Upload File Up To 5MB |

Top comments (0)