S.Richmond
S.Richmond

Reputation: 11558

Adding Google API Offline access to a .NET Core app

I've written an ASP.NET Core webapp that uses Auth0 as its primary authorization mechanism for users, which middlemans a whole bunch of external auth endpoints like Google and Facebook. That works fine and I have no issues there.

At its core the webapp makes use of Google Analytics to perform its own analytics and business logic. The Google Analytics account that is being analysed by my webapp could and is likely different from the users' own Google account. To be clear what I mean is that it is likely the user will login with whatever login provider they wish, and then they'll attach a specific Google business account with access to their businesses Google Analytics system.

The webapp performs analytics both whilst the user is logged in, and whilst the user is offline.

So I've always kept the user auth (Auth0) step seperate from the auth of the Analytics account step. The general process is as follows:

  1. User logs in via Auth0 using whatever provider (Google, Facebook, email/pass) and accesses the private dashboard.
  2. User sets up a "Company" and clicks on a button to authorize our webapp access to a specific Google account with Analytics on it.
  3. User is redirected back to the private dashboard and the refresh token of the Google account is stored for future use.

Previously I had been pushing the Analytics auth through Auth0 as well, and I used a cached Auth0 refresh token to do work offline. However it expires after some days and Auth0 don't appear to provide long-term offline access.

So I figure the easiest thing to do would be to simply not use auth0 for the Analytics auth step, auth directly with the Google API and store the Google refresh token long-term. However I cannot find any concrete examples of how to achieve this!

Upvotes: 4

Views: 1199

Answers (1)

S.Richmond
S.Richmond

Reputation: 11558

I finally cracked it! I ended up throwing away all the libraries and found that it was simplest to use the plain old REST API. Code example below for those curious:

The users' browser GETs the following and is redirected to Google for an auth token:

public IActionResult OnGet([FromQuery]int id, [FromQuery]string returnAction)
{
    var org = context.Organizations.Include(o => o.UserOrgs).First(o => o.Id == id);
    var user = GetUser();

    if (!IsUserMemberOfOrg(user, org)) return BadRequest("User is not a member of this organization!");

    var redirectUri = Uri.EscapeUriString(GetBaseUri()+"dash/auth/google?handler=ReturnCode");
    var uri = $"https://accounts.google.com/o/oauth2/v2/auth?"+
            $"scope={Uri.EscapeUriString("https://www.googleapis.com/auth/analytics.readonly")}"+
            $"&prompt=consent"+
            $"&access_type=offline"+
            //$"&include_granted_scopes=true"+
            $"&state={Uri.EscapeUriString(JsonConvert.SerializeObject(new AuthState() { OrgId = id, ReturnAction = returnAction }))}"+
            $"&redirect_uri={redirectUri}"+
            $"&response_type=code"+
            $"&client_id={_configuration["Authentication:Google:ClientId"]}";

    return Redirect(uri);
}

Google redirects back to the following, and which point I perform a POST from the webserver to a Google API to exchange the auth token for a refresh token and store it for later:

public async Task<IActionResult> OnGetReturnCode([FromQuery]string state, [FromQuery]string code, [FromQuery]string scope)
{
    var authState = JsonConvert.DeserializeObject<AuthState>(state);

    var id = authState.OrgId;
    var returnAction = authState.ReturnAction;

    var org = await context.Organizations.Include(o => o.UserOrgs).SingleOrDefaultAsync(o => o.Id == id);
    if (org == null) return BadRequest("This Org doesn't exist!");
    using (var httpClient = new HttpClient())
    {
        var redirectUri = Uri.EscapeUriString(GetBaseUri()+"dash/auth/google?handler=ReturnCode");

        var dict = new Dictionary<string, string>
        {
            { "code", code },
            { "client_id", _configuration["Authentication:Google:ClientId"] },
            { "client_secret", _configuration["Authentication:Google:ClientSecret"] },
            { "redirect_uri", redirectUri },
            { "grant_type", "authorization_code" }
        };

        var content = new FormUrlEncodedContent(dict);
        var response = await httpClient.PostAsync("https://www.googleapis.com/oauth2/v4/token", content);

        var resultContent = JsonConvert.DeserializeObject<GoogleRefreshTokenPostResponse>(await response.Content.ReadAsStringAsync());

        org.GoogleAuthRefreshToken = resultContent.refresh_token;
        await context.SaveChangesAsync();

        return Redirect($"{authState.ReturnAction}/{authState.OrgId}");
    }
}

Finally, we can get a new access token with the refresh token later on without user intervention:

public async Task<string> GetGoogleAccessToken(Organization org)
{
    if(string.IsNullOrEmpty(org.GoogleAuthRefreshToken))
    {
        throw new Exception("No refresh token found. " +
            "Please visit the organization settings page" +
            " to setup your Google account.");
    }

    using (var httpClient = new HttpClient())
    {
        var dict = new Dictionary<string, string>
        {
            { "client_id", _configuration["Authentication:Google:ClientId"] },
            { "client_secret", _configuration["Authentication:Google:ClientSecret"] },
            { "refresh_token", org.GoogleAuthRefreshToken },
            { "grant_type", "refresh_token" }
        };
        var resp = await httpClient.PostAsync("https://www.googleapis.com/oauth2/v4/token", 
            new FormUrlEncodedContent(dict));

        if (resp.IsSuccessStatusCode)
        {
            dynamic returnContent = JObject.Parse(await resp.Content.ReadAsStringAsync());
            return returnContent.access_token;
        } else
        {
            throw new Exception(resp.ReasonPhrase);
        }
    }
}

Upvotes: 6

Related Questions