14
votes

I've been involved with building an internal-use application through which users may upload files, to be stored within Google Drive. As it is recommended not to use service accounts as file owners, I wanted to have the application upload on behalf of a designated user account, to which the company sysadmin has access.

I have created the application, along with a service account. There are two keys created for the service account, as I have tried both the JSON and PKCS12 formats trying to achieve this:

API Manager Credentials page

I have downloaded the OAuth 2.0 client ID details, and also have the .json and .p12 files for the service account keys (in that order as displayed above):

Downloaded files for client and service accounts

I had my sysadmin go through the steps detailed here to delegate authority for Drive API access to the service account: https://developers.google.com/drive/v2/web/delegation#delegate_domain-wide_authority_to_your_service_account

We found that the only thing that worked for the "Client name" in step 4 was the "Client ID" listed for the Web application (ending .apps.googleusercontent.com). The long hexadecimal IDs listed for the Service account keys were not what it required (see below):

Authorized API client access

Previously to the above, I had code which would create a DriveService instance that could upload directly to the service account, referencing the .json file for the service account keys:

private DriveService GetServiceA()
{
    var settings = SettingsProvider.GetInstance();

    string keyFilePath = HostingEnvironment.MapPath("~/App_Data/keyfile.json");
    var scopes = new string[] { DriveService.Scope.Drive };

    var stream = new IO.FileStream(keyFilePath, IO.FileMode.Open, IO.FileAccess.Read);
    var credential = GoogleCredential.FromStream(stream);
    credential = credential.CreateScoped(scopes);

    var service = new DriveService(new BaseClientService.Initializer()
    {
        HttpClientInitializer = credential,
        ApplicationName = "MyAppName"
    });

    return service;
}

That works for listing and uploading, though of course there's no web UI for access to the files, and it seems as though it doesn't handle things like permissions metadata or generation of thumbnails for e.g. PDFs. This is why I'm trying to use a standard account for the uploads.

Once the delegation was apparently sorted, I then attempted to adapt the code shown in the delegation reference linked above, combining with code from elsewhere for extracting the necessary details from the .json key file. With this code, as soon as I try to execute any API command, even as simple as:

FileList fileList = service.FileList().Execute();

I receive an error:

Exception Details: Google.Apis.Auth.OAuth2.Responses.TokenResponseException: Error:"unauthorized_client", Description:"Unauthorized client or scope in request.", Uri:""

The code for that effort is:

private DriveService GetServiceB()
{
  var settings = SettingsProvider.GetInstance();

  string keyFilePath = HostingEnvironment.MapPath("~/App_Data/keyfile.json");
  string serviceAccountEmail = "<account-email>@<project-id>.iam.gserviceaccount.com";
  var scopes = new string[] { DriveService.Scope.Drive };

  var stream = new IO.FileStream(keyFilePath, IO.FileMode.Open, IO.FileAccess.Read);
  var reader = new IO.StreamReader(stream);
  string jsonCreds = reader.ReadToEnd();
  var o = JObject.Parse(jsonCreds);
  string privateKey = o["private_key"].ToString();

  var credential = new ServiceAccountCredential(
    new ServiceAccountCredential.Initializer(serviceAccountEmail)
    {
      Scopes = scopes,
      User = "[email protected]"
    }
    .FromPrivateKey(privateKey)
  );

  var service = new DriveService(new BaseClientService.Initializer()
  {
    HttpClientInitializer = credential,
    ApplicationName = "MyAppName"
  });

  return service;
}

Finally, I created the second service account key to save a .p12 file in order to more closely match the code in the authority delegation documentation, but which results in the same exception:

private DriveService GetServiceC()
{
  var settings = SettingsProvider.GetInstance();

  string p12KeyFilePath = HostingEnvironment.MapPath("~/App_Data/keyfile.p12");
  string serviceAccountEmail = "<account-email>@<project-id>.iam.gserviceaccount.com";
  var scopes = new string[] { DriveService.Scope.Drive }; // Full access

  X509Certificate2 certificate = new X509Certificate2(
    p12KeyFilePath,
    "notasecret",
    X509KeyStorageFlags.Exportable
  );

  var credential = new ServiceAccountCredential(
    new ServiceAccountCredential.Initializer(serviceAccountEmail)
    {
      Scopes = scopes,
      User = "[email protected]"
    }
    .FromCertificate(certificate)
  );

  var service = new DriveService(new BaseClientService.Initializer()
  {
    HttpClientInitializer = credential,
    ApplicationName = "MyAppName"
  });

  return service;
}

The minimial relevant class where this method lives is:

public class GoogleDrive
{
  public DriveService Service { get; private set; }

  public GoogleDrive()
  {
    this.Service = this.GetService();
  }

  private DriveService GetService()
  {
    // Code from either A, B or C
  }

  public FilesResource.ListRequest FileList()
  {
    return this.Service.Files.List();
  }
}

And that's used in this fashion:

var service = new GoogleDrive();
FilesResource.ListRequest listRequest = service.FileList();
FileList fileList = listRequest.Execute();

The exception occurs on that last line.

I do not understand why my service account cannot act on behalf of the designated user, which is part of the domain for which the application's service account should have delegated authority. What is it that I've misunderstood here?

3
This might be a matter of configuration error. Did you enter the service account's Client ID in the Client name? It's step #5 in the Delegate domain-wide authority to your service accountnoogui
@noogui The .json file generated for the service account key contains a "client_id" entry, which is a 21 digit decimal number. Using that for step #5 shows an error message: "This client name has not been registered with Google yet."Matt Sach

3 Answers

7
votes

I have found the answer myself, and it was configuration, not code. The link I shared with the steps for delegation of authority does not mention an option available when creating the service account: a checkbox saying that the account will be eligible for domain-wide delegation (DwD).

This link describes the service account creation and delegation more accurately: https://developers.google.com/identity/protocols/OAuth2ServiceAccount

I did not know about DwD when I created the service account, and so I had not selected that option. It is possible to go back and edit a service account to select it. Once I did this, I was able to retrieve a correct client ID for use in the "Manage API Client Access" part of the admin console. Using the GetServiceC() method then works as intended, and I am able to retrieve files for users in the same Apps domain.

This is the checkbox that needs to be ticked for a service account to be eligible for domain-wide delegation of authority:

Creation of a new service account - DwD checkbox

This is the extra information available once you've done that (with a throwaway service account alongside that did not have the box ticked, for comparison):

Comparison of service accounts with and without DwD

1
votes

You may tick the checkbox Enable G Suite Domain-wide Delegation, when you create the service account on the admin panel.

Regards

-2
votes

Most everything looks ok but:

A. Use ServiceC code, not sure if the object typing matters but your line:

var credential = new ServiceAccountCredential...

should be

ServiceAccountCredential credential = new ServiceAccountCredential...

B. Check that the P12 file in ServiceC is the real P12 file you actually uploaded to your environment where you're running this.

C. update your question with the exact runable code you're using to create and invoke your service:filelist:execute code. This way there's more clarity and less assumptions.