Register a HubSpot Integration App with Azure Functions

About Hubspot

Let’s start from the beginning. What is HubSpot? HubSpot is a Marketing-, Sales-, Service, CMS and Operations-Software. Learn more about HubSpot here.

What I like in particular with HubSpot is the integration possibilities it offers. It has a rich API and also the capability to call webHooks among other things.

However there are two ways to authenticate against the HubSpot API

  • API-Key
  • OAUTH

Hubspot itself disencourages you from using the API-Key other than for testing/developing purposes and given that an API-Key has no permissions attached to it and gives you control to almost everything in HubSpot you should never hand out the API-Key of a production system!

To develop applications you can register for a free developer account and sandbox for testing.

Concept and registration process of HubSpot Applications

HubSpot allows ISV’s (independent software vendors) to write integrations and offer those as „Hubspot-Integration-Apps“ to all customers of HubSpot. Therefore your integration always must be multi-tenant per se. Although there are some „workarounds“ to only allow access to certain HubSpot-Tenants as we see later.

As an ISV you register an „HubSpot-Application“ inside your Developer Account with HubSpot. Here you will gain access to a so called „Installation-Url“ that you can offer your customers to install you Applications in their HubSpot-Tenants. If you also publish your HubSpot-Application to the HubSpot-Store all customers will see this integration and can choose to install your application.

As you can see in the illustration below „your“ ISV Application from your Developer-Tenant is installed in the tenants of Customers A and B. Customer C has not installed „your“ ISV Application.

So how does the „Install-Process“ actually work conceptually? While registering your HubSpot-Application (this process is quite straight forward) you need to provide an End-Point that HubSpot can call once a customer decides to install the application to their tenant.

You also declare what permissions you require at a minimum so your integration works properly (For example: Access to Contacts)

If the user clicks on the installation url (which by the way can contain additional optional permissions) they have to confirm that they want to give access to the application.

Then the endpoint (ISV) simply will receive an „CODE“ that must be redeemed to get an Access Token from HubSpot. The base code template for an Azure Function App would look like this:

[FunctionName("RegisterHubSpotApp")]
public async Task<IActionResult> RegisterHubSpotApp(
    [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = null)] 
    HttpRequest req,
    ILogger log)
{
    try
    {
        var code = req.Query["code"].FirstOrDefault();
        if (!string.IsNullOrWhiteSpace(code))
        {
            // Redeem Code here!
        }
    }
    catch (Exception ex)
    {
        log.LogError(ex, ex.Message);
        return new BadRequestObjectResult(ex.Message);
    }
    return new BadRequestResult();
}

Unfortunately we still do not know which Customer Tenant did choose to install our application! The next step would be to acquire the Access-Token and Refresh-Token! Which still does not provide us with the information about the tenant!

Access-Token: This is the BEARER-Token you must use in the „Authorization„-Header when you make calls to the HubSpot-API. This will only be valid for a certain amount of time (see „ExpiresIn„-Field)

Refresh-Token: This is the token you must keep secure at all costs! Best to store it somewhere secure like Azure KeyVault. Having this token you can request new Access-Tokens that have expired! [SECURITY WARNING]

Said that to acquire the Access/Refresh-Token you need to provide following information that you can obtain from your registered HubSpot-Application:

  • ClientId
  • ClientSecret
  • RedirectUri (your Endpoint, where the Code was sent)

A simple implementation in an Azure Function might look like this:

public class OAuthTokenResponse
    {
        [JsonProperty(PropertyName = "token_type")]
        public string TokenType { get; set; }
        [JsonProperty(PropertyName = "refresh_token")]
        public string RefreshToken { get; set; }
        [JsonProperty(PropertyName = "access_token")]
        public string AccessToken { get; set; }
        [JsonProperty(PropertyName = "expires_in")]
        public int ExpiresIn { get; set; }
    }

private const string HUBSPOT_TOKEN_API = "https://api.hubapi.com/oauth/v1/token";

private async Task<OAuthTokenResponse> GetOAuthToken(string code)
{
    HttpClient client = _httpFactory.CreateClient();

    List<KeyValuePair<string, string>> data = new List<KeyValuePair<string, string>>()
        {
            new KeyValuePair<string, string>("grant_type","authorization_code"),
            new KeyValuePair<string, string>("client_id",_config["clientID"]),
            new KeyValuePair<string, string>("client_secret",_config["clientSecret"]),
            new KeyValuePair<string, string>("redirect_uri", _config["RedirectUri"]),
            new KeyValuePair<string, string>("code", code)
        };
    HttpRequestMessage httpRequest = new HttpRequestMessage
    {
        RequestUri = new Uri(HUBSPOT_TOKEN_API),
        Content = new FormUrlEncodedContent(data),
        Method = HttpMethod.Post
    };
    HttpResponseMessage httpResponse = await client.SendAsync(httpRequest);
    if (httpResponse.IsSuccessStatusCode)
    {
        var token = JsonConvert.DeserializeObject<OAuthTokenResponse>(await httpResponse.Content.ReadAsStringAsync());
        if (token != null && !string.IsNullOrWhiteSpace(token.RefreshToken))
        {
            return token;
        }
    }
    else
    {
        var response = await httpResponse.Content.ReadAsStringAsync();
    }
    return null;
}

So there is a lot of effort to be done and still we have no idea which Tenant has requested the install! Fortunately we can use the RefreshToken (keep in mind to keep it secure!) to retrieve some information about it.

The result of this API Call will look like this:

{
  "token": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "user": "test@hubspot.com",
  "hub_domain": "demo.hubapi.com",
  "scopes": [
    "automation",
    "contacts",
    "oauth"
  ],
  "hub_id": 62515,
  "client_id": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy",
  "user_id": 123,
  "token_type": "refresh"
}

and gives us information about

  • the user who installed the application („user“, „user_id“)
  • the scopes we have available and can call apis for („scopes“)
  • the client-ID of our registered HubSpot-API (in case we have more than one integration app)
  • Finally the tenant-ID („hub_id“)

You might have noticed using Hubspot that the „hub_id“ is always a part of the URL. Example of an url to a contact:

An implementation in Azure Functions might look like this:

private async Task<OAuthRefreshTokenInfo> GetRefreshTokenInfo(string refreshToken)
{
    HttpClient client = _httpFactory.CreateClient();

    HttpResponseMessage tokenInfoResponse = await client.GetAsync(string.Format("https://api.hubapi.com/oauth/v1/refresh-tokens/{0}", refreshToken));
    if (tokenInfoResponse.IsSuccessStatusCode)
    {
        OAuthRefreshTokenInfo tokenInfo = JsonConvert.DeserializeObject<OAuthRefreshTokenInfo>(await tokenInfoResponse.Content.ReadAsStringAsync());
        if (tokenInfo.ClientId == _config["clientID"])
        {
            return tokenInfo;
        }
    }
    return null;
}

After we do have AccessToken, RefreshToken and Tenant-Information we need to store the Refresh-Token together with the Tenant-Information so our integration application can use this information to create an AccessToken on demand.

Make sure you cache the AccessToken securely to prevent unnecessary round-trips in your integration. And make sure the RefreshToken is stored securely (f.e. Azure KeyVault)!

Thanks and enjoy

Nina

Cosmos DB RBAC-Access with Managed Identities

The public preview of role-based access control (RBAC) for the Azure Cosmos DB Core (SQL) API was announced yesterday at Microsoft Ignite. This does not only allow us to authenticate our requests with an Azure Active Directory identity based on roles, but also allows to audit the identities which accessed your data.

In this blog post I walk you through a complete example on how you can use Azure Cosmos DB with RBAC and managed identity. We will

  • Create following Azure Resources
    • Cosmos DB Account
    • Log Analytics Account
    • Azure Function App
  • Create a Azure Cosmos DB role with specific access permissions
  • Assign the CosmosDB role to a managed identity
  • Write an Azure Function App to access CosmosDB with managed identity

Create and configure Azure Resources

Open https://portal.azure.com and create a new resource group „cosmosrbac„.

Create a new Azure Cosmos DB Account in that resource group. For this sample I had been using the new serverless option. Use the DataExplorer (under Settings) to create a new Database „demodb“ and a Collection „democol„. I am using „/p“ as a generic partition key. This is especially useful, if you store different kind of documents in your collection which are partitioned differently. Also create a document with the „New document“ button since our permissions will be read only later.

{
"id":"001",
"p":"Test",
"First":"Andreas"
}

Now create a new Log Analytics Workspace and a Azure Function App (serverless) in the resource group „cosmosrbac„. I named those „cosmosrbaclog“ and „cosmosrbacfunc

Now lets configure the Azure Cosmos DB account so that all Dataplane Requests are audited in our Log Analytics Workspace. To do that select your Azure Cosmos DB resource, click on „Diagnostic settings“ in the section „Monitoring„. Add a new diagnostic setting where you just select „DataPlaneRequests“ and your created Log Analytics Workspace. I named that setting „SecurityLog“.

Diagnostic settings for Azure Cosmos DB account

In the portal select your Azure App Function and click on „Identity“ in the section „Settings„. Select „System assigned“ and turn the Status to „On„. Write down the given Object ID which represents the Object ID of the Service Principal in Azure AD. Read more about the managed identity types in the documentation here.

System assigned Identity for Azure Function App

Configure CosmosDB RBAC role and assign to identity

To create a new CosmosDB RBAC role we need to define which permissions the role consists of. This can be done by defining a JSON file. We will be creating a new role „MyReadOnlyRole“ with following permissions:

  • Microsoft.DocumentDB/databaseAccounts/readMetadata
  • Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read
  • Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/executeQuery
  • Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/readChangeFeed

You can find all available Cosmos DB permissions here.

In the Azure Portal open the cloud shell. You can find the cloud shell in the upper bar on the right side. I am using PowerShell version! If you use bash instead you might need to slightly adopt the statements below. To create and assign the role you need the latest Azure Cosmos DB az extension which you can install with this command:

az extension add --name cosmosdb-preview

Then start the nano editor and create a new file „roledefinition.json

nano roledefinition.json

Copy and paste the following json document and save the document by pressing CTRL+Q and selecting Y:

{
    "RoleName": "MyReadOnlyRole",
    "Type": "CustomRole",
    "AssignableScopes": ["/"],
    "Permissions": [{
        "DataActions": [
            "Microsoft.DocumentDB/databaseAccounts/readMetadata",
            "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read",
            "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/executeQuery",
            "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/readChangeFeed"
        ]
    }]
}

Now create the defined role with the following statements. Make sure you replace the values of $resourceGroupName and $accountName with those you selected:

$resourceGroupName='cosmosrbac'
$accountName='cosmosrbac'
az cosmosdb sql role definition create --account-name $accountName --resource-group $resourceGroupName --body roledefinition.json

To later get a list of your defined roles you can issue this statement:

az cosmosdb sql role definition list --account-name $accountName --resource-group $resourceGroupName

Write down the value of „name“ of the new created role definition as we will need it in our next step.

Now we assign this new role to our previously created system managed identity. Replace the $readOnlyRoleDefinitionId value with the value of the role-name (see above) and replace the $principalId value with the Object ID we got earlier when we configured our Azure Function App:

$resourceGroupName='cosmosrbac'
$accountName='cosmosrbac'
$readOnlyRoleDefinitionId = 'name of role definition'
$principalId = 'object id of system managed identity'

az cosmosdb sql role assignment create --account-name $accountName --resource-group $resourceGroupName --scope "/" -p $principalId --role-definition-id $readOnlyRoleDefinitionId

We now can use this system managed identity from Azure Function App to access Cosmos DB with the permissions we defined for that role. To see all the assignments you made you execute this statement:

az cosmosdb sql role assignment list --account-name $accountName --resource-group $resourceGroupName

Access Azure CosmosDB with Azure Function App

Now start Visual Studio 2019 and create a new function app with a HTTP Trigger. Right click the new created project and select „Manage nuget packages…“. Make sure the checkbox „Include prerelease“ is checked! Add the following nuget packages to your solution:

  • Azure.Identity (Version 1.3.0)
  • Microsoft.Azure.Cosmos (Version 3.17.0-preview1)
Functionality is only in the preview of 3.17.0 but not in the final release!

Modify Function1.cs file as following. First replace the using statements with these:

using Azure.Identity;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Cosmos;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using System;
using System.Threading.Tasks;

Add a person class which represents our documents in Azure Cosmos DB

public class Person
{
    public string Id { get; set; }
    public string p { get; set; }
    public string First { get; set; }
}

Inside the class „public static class Function1“ write following two function. One to read an item and another to write an item. Make sure that you replace <yourAccount> with the name of your Azure Cosmos DB account name:

[FunctionName("GetItem")]
public static async Task<IActionResult> GetItem(
  [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
   ILogger log)
{
  try
    {
      // Since we know that we have a managed identity we instatiate that directly
      // var tokenCredential = new DefaultAzureCredential();
      ManagedIdentityCredential tokenCredential = new ManagedIdentityCredential();
      CosmosClient client = new
       CosmosClient("https://<yourAccount>.documents.azure.com:443/", tokenCredential);
      Container container = client.GetContainer("demodb", "democol");
      ItemResponse<Person> res = await container.ReadItemAsync<Person>("001", new PartitionKey("Test"));
      return new OkObjectResult(JsonConvert.SerializeObject(res.Resource));
    }
    catch (Exception ex)
    {
      return new BadRequestObjectResult(ex.ToString());
    }
}
[FunctionName("CreateItem")]
public static async Task<IActionResult> CreateItem(
    [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
    ILogger log)
{
  try
  {
    ManagedIdentityCredential tokenCredential = new ManagedIdentityCredential();
    CosmosClient client = 
      new CosmosClient("https://<yourAccount>.documents.azure.com:443/", tokenCredential);
    Container container = client.GetContainer("demodb", "democol");
    ItemResponse<Person> res = await container.CreateItemAsync<Person>(
	new Person() 
	{ Id = "002", 
	  p = "Test", 
          First = "Sonja" }, 
	new PartitionKey("Test"));
    return new OkObjectResult(JsonConvert.SerializeObject(res.Resource));
  }
  catch (Exception ex)
  {
    return new BadRequestObjectResult(ex.ToString());
  }
}

Compile the application and publish it to the Azure function app (Right click project – Publish). Open the Azure portal again and navigate to your Azure function app. Under the section „Function“ select „Functions„. Select each function and aquirce a function URL with the „Get function Url“ buttom in the upper bar.

Azure Functions deployed

Now use a tool like Postman to try to execute your functions. While the GetItem-Function will return a result

GetItem Function returning data from Azure Cosmos DB

the CreateItem-Function will return an RBAC access error.

CreateItem Function returns an error claiming that the principal does not have the required RBAC permissions

Analyzing Loganalytics Azure CosmosDB Dataplane

Navigate now to your Azure Log Analytics Workspace in the Azure Portal. Under the section „General“ select „Logs„. Issuing the following statement will show the failed access to the document:

AzureDiagnostics 
| where ResourceProvider == "MICROSOFT.DOCUMENTDB"
  and Category == "DataPlaneRequests"
  and ResourceGroup == "COSMOSRBAC"
  and requestResourceType_s == "Document"
| where OperationName == 'Create'
| summarize by statusCode_s, OperationName, aadPrincipalId_g, aadAppliedRoleAssignmentId_g

Replacing the Operation Name with „Read“ on the other hand will show all 200 success code for that given principal id.

AzureDiagnostics 
| where ResourceProvider == "MICROSOFT.DOCUMENTDB"
  and Category == "DataPlaneRequests"
  and ResourceGroup == "COSMOSRBAC"
  and requestResourceType_s == "Document"
| where OperationName == 'Read'
| summarize by statusCode_s, OperationName, aadPrincipalId_g, aadAppliedRoleAssignmentId_g

I hope you enjoyed our tour!

Kind regards
AndiP