Kirk Woll
Dec 24
Creating a public image in Google Drive without middleware in ASP.NET Core

Goal

To allow a user to authenticate with Google and allow your app to create a photo that can be viewed by anyone with the proper link.

I find using the built-in middleware provided by ASP.NET Core somewhat frustrating as so much is hidden from you it's difficult to understand what is going on. (for example, they create the UI for you, assume database contexts, and it's generally more work to customize than doing things in a vanilla fashion)

Approach

I decided what I wanted was to handle the authentication portion myself using my own handlers without involving any pre-built middleware at all. I'm using ASP.NET Core with Razor Pages. Then once the authentication story is dealt with, use the Google Drive API v3 nuget package to do all the work. Before you begin, set up your app in the Google Console as directed by Google's documentation.

Authentication:

This involves two Razor pages. You could go about it with just one unique page and a redirect in a different page handler. But for our purposes we'll use one page to redirect to google's sign in URL, and another page to receive the callback after the user signs in on the Google side.

Login.cshtml.cs:
The cshtml portion is not used (aside from the page directive), so it doesn't matter what it looks like. But the model (contained in this file) should look something like this:

public class LoginModel : PageModel
{
    public IActionResult OnGet()
    {
        using (var provider = new RNGCryptoServiceProvider())
        {
            // Create a cookie for the request forgery token
            var bytes = new byte[128];
            provider.GetBytes(bytes);
            var antiForgery = Convert.ToBase64String(bytes);
            HttpContext.Response.Cookies.Append(".Login.Antiforgery", antiForgery, new CookieOptions
            {
                HttpOnly = true,
                SameSite = SameSiteMode.None,
                Path = "/",
                Expires = DateTimeOffset.Now.AddHours(1),
                IsEssential = true
            });

            var redirectUrl = GoogleApi.GetSignInUrl(antiForgery);
            return Redirect(redirectUrl);
        }
    }
}

So what does this handler do? The bulk of it is to store a cryptographically secure anti-fogery token and store it in a cookie for later retrieval in the callback. It then redirects to google to perform the actual sign in and authorization. Once the user authorizes our app, Google will redirect them to our next page.

More on the GoogleApi class at the end.

SignInGoogle.cshtml:
Similarly to the login page, the cshtml doesn't do a whole lot, but we do override the page directive:

@page "/signin-google"
@model SoWhenExactly.Pages.SignInGoogleModel

SignInGoogle.cshtml.cs:

public class SignInGoogleModel : PageModel
{
    public async Task<IActionResult> OnGetAsync(string state, string code)
    {
        var antiForgery = HttpContext.Request.Cookies.FirstOrDefault(x => x.Key.StartsWith(".Login.Antiforgery")).Value;

        // There might be + characters in the cookie to indicate a space, so we decode
        // it so it will match state (which would have already been decoded)
        var antiForgeryDecoded = WebUtility.UrlDecode(antiForgery);

        if (state != antiForgeryDecoded)
        {
            return Unauthorized();
        }

        var (accessToken, _) = await GoogleApi.ValidateSignIn(code);
        HttpContext.Response.Cookies.Append(".google-token", accessToken, new CookieOptions
        {
            HttpOnly = true,
            Path = "/",
            Expires = DateTimeOffset.Now.AddHours(1),
            IsEssential = true
        });

        return RedirectToPage("Index");
    }
}

Again, this page is the page Google will redirect to after the user has authorized your app. First we compare the anti fogery token we passed to Google with the token we stored in the cookie. If this doesn't match, we return unauthorized.

Next we validate the sign in (again using the GoogleApi class described later). Finally, we store the user's access token in a cookie for later use.

Uploading a file to Google Drive

What follows is a code snippet you can put where you like to actually upload the file to Google Drive and obtain a public URL you can share with others.

var accessToken = HttpContext.Request.Cookies[".google-token"];
if (accessToken != null)
{
    var service = new DriveService(new BaseClientService.Initializer
    {
        HttpClientInitializer = GoogleCredential.FromAccessToken(accessToken),
        ApplicationName = "SoWhenExactly"
    });

    using (var stream = new FileStream(@"c:\temp\diff1.txt", FileMode.Open))
    {
        var file = new File
        {
            Description = "Test File1",
            Name = "TestFile1"
        };
        var upload = service.Files.Create(
            file,
            stream,
            "text/plain");
        await upload.UploadAsync();

        var permissionRequest = service.Permissions.Create(
            new Permission
            {
                Role = "reader",
                Type = "anyone"
            },
            upload.ResponseBody.Id);

        await permissionRequest.ExecuteAsync();

        var getRequest = service.Files.Get(upload.ResponseBody.Id);
        getRequest.Fields = "id, webContentLink";
        var getRequestResponse = await getRequest.ExecuteAsync();

        var link = getRequestResponse.WebContentLink;
        // A url you can share with others and is publicly accessible to the world
    }
}

GoogleApi.cs:
This is a helper class to handle redirecting to Google for authorization and processing the callback to obtain an access token for use with Google Apis.

public class GoogleApi
{
    public static string GetSignInUrl(string antiForgery)
    {
        var googleSettings = Environment.GetEnvironmentVariable("SOWHEN_GOOGLEAPI");
        var googleSettingsJson = JObject.Parse(googleSettings);
        var googleClientId = (string)googleSettingsJson["web"]["client_id"];

        using (var provider = new RNGCryptoServiceProvider())
        {
            var seedBytes = new byte[32];
            provider.GetBytes(seedBytes);
            var seed = BitConverter.ToInt32(seedBytes);
            var random = new Random(seed);
            var nonceParts = Enumerable.Repeat(0, 3).Select(_ => random.Next(100000, 999999).ToString()).ToArray();
            var nonce = string.Join("-", nonceParts);

            var url = $"https://accounts.google.com/o/oauth2/v2/auth?client_id={googleClientId}&response_type=code&scope=openid%20email%20https://www.googleapis.com/auth/drive.file&redirect_uri=https://localhost:44324/signin-google&state={antiForgery}&openid.realm=localhost&nonce={nonce}";
            return url;
        }
    }

    public static async Task<(string accessToken, JwtSecurityToken jwtToken)> ValidateSignIn(string code)
    {
        var googleSettings = Environment.GetEnvironmentVariable("SOWHEN_GOOGLEAPI");
        var googleSettingsJson = JObject.Parse(googleSettings);
        var googleClientId = (string)googleSettingsJson["web"]["client_id"];
        var googleSecret = (string)googleSettingsJson["web"]["client_secret"];

        using (var client = new HttpClient())
        {
            var values = new List<KeyValuePair<string, string>>
            {
                new KeyValuePair<string, string>("code", code),
                new KeyValuePair<string, string>("client_id", googleClientId),
                new KeyValuePair<string, string>("client_secret", googleSecret),
                new KeyValuePair<string, string>("redirect_uri", "https://localhost:44324/signin-google"),
                new KeyValuePair<string, string>("grant_type", "authorization_code")
            };
            var content = new FormUrlEncodedContent(values);
            var response = await (await client.PostAsync("https://www.googleapis.com/oauth2/v4/token", content)).Content.ReadAsStringAsync();
            var responseJson = JObject.Parse(response);
            var accessToken = (string)responseJson["access_token"];
            var idToken = (string)responseJson["id_token"];
            var jwtToken = new JwtSecurityToken(idToken);
            var expiresIn = (string)responseJson["expires_in"];
            var tokenType = (string)responseJson["token_type"];

            return (accessToken, jwtToken);
        }
    }
}

Note your comment will be put in a review queue before being published.