PKCE OAuth and the Plugin SDK for Windows

Authorization Code with Proof Key for Code Exchange (PKCE) is an OAuth flow similar to the standard Authorization Code flow, except it doesn't require that you have a backend server to get the authorization token.

Prerequisites

  • Understanding of C++ or C#.
  • Visual Studio 2019 or later.
  • Zoom Plugin SDK for Windows.
  • Zoom Plugin SDK and OAuth credentials for a Marketplace app with the scopes your plugin needs.

NOTE Throughout this guide, credentials are hardcoded for convenience. For security reasons, do not store hardcoded credentials of any type in your production application.

After you've created your project, downloaded the Plugin SDK files, and integrated the SDK into your app, set up a custom URL scheme. Then authenticate the user with PKCE and use the returned access token to call the Zoom REST APIs your plugin's scopes authorize.

Set up a custom URL scheme

Follow the steps on Microsoft's web site to set up a custom URL scheme for Windows.

Authenticate users with PKCE

Now that your app is configured to handle your custom URI scheme, start implementing the PKCE OAuth flow.

Generate the code verifier and challenge

First, generate a code verifier using the code below, then hash the verifier to create a code challenge. Host these in a dedicated CodeChallengeHelper class. A field inside the class will also be needed to store the verifier, since the same verifier used to generate the challenge must be provided when requesting an access token.

Within that same class, define methods createCodeVerifier to generate a new verifier, and getCodeChallenge to create a code challenge using the verifier.

class CodeChallengeHelper
{
public:
 static std::string verifier;
 static void createCodeVerifier();
 static std::string getCodeChallenge();
};
using System.Security.Cryptography;
using System.Text;
namespace WinFormsApp1
{
    internal class CodeChallengeHelper
    {
        static public String verifier { get; private set; }
    }
}

Within that same class, define methods createCodeVerifier to generate a new verifier, and getCodeChallenge to create a code challenge using the verifier.

static public void createCodeVerifier()
{
    var rng = RandomNumberGenerator.Create();
    var byteArray = new byte[32];
    rng.GetBytes(byteArray);
    char[] padding = { '=' };
    verifier = Convert.ToBase64String(byteArray).TrimEnd(padding).Replace('+', '-').Replace('/', '_'); ;
}
static public String getCodeChallenge(String verifier)
{
    var byteArray = Encoding.ASCII.GetBytes(verifier);
    var md = HashAlgorithm.Create("SHA-256");
    md?.TransformFinalBlock(byteArray, 0, byteArray.Length);
    var digest = md?.Hash;
    char[] padding = { '=' };
    return Convert.ToBase64String(digest).TrimEnd(padding).Replace('+', '-').Replace('/', '_');
}

When using CodeChallengeHelper, call createCodeVerifier to create a verifier before accessing the verifier field or the return value of getCodeChallenge.

Implement the two methods

void CodeChallengeHelper::createCodeVerifier()
{
    char byteArray[32];
    randombytes_buf(byteArray, 32);
    BYTE* Bytes = reinterpret_cast<BYTE*>(const_cast<char*>(byteArray));
    char base64[256];
    DWORD base64Len;
    if (CryptBinaryToStringA(Bytes, 32, CRYPT_STRING_BASE64 | CRYPT_STRING_NOCRLF,base64, &base64Len))
    {
        verifier = std::string(base64);
        strFindAndReplace(verifier, "+", "-");
        strFindAndReplace(verifier, "/", "_");
        trimPadding(verifier);
    }
}
string CodeChallengeHelper::getCodeChallenge()
{
    const char* byteArray = CodeChallengeHelper::verifier.c_str();
    unsigned char hash[SHA256_DIGEST_LENGTH];
    SHA256_CTX sha256;
    SHA256_Init(&sha256);
    SHA256_Update(&sha256, byteArray, strlen(byteArray));
    SHA256_Final(hash, &sha256);
    char base64[256];
    DWORD base64Len;
    if (CryptBinaryToStringA(hash, 32, CRYPT_STRING_BASE64 | CRYPT_STRING_NOCRLF, base64, &base64Len))
    {
        auto base64Str = string(base64);
        strFindAndReplace(base64Str, "+", "-");
        strFindAndReplace(base64Str, "/", "_");
        trimPadding(base64Str);
        return base64Str;
    }
    return string();
}

In the implementation of the two methods above, we used multiple third party libraries and customized functions. You need to add declarations and definitions to the class source file. For PKCE processing, encode the "verifier" and its "challenge" with BASE64 without padding and be safe to be included in an URL. Use strFindAndReplace to replace characters that are not safe for the URL, and use trimPadding to remove BASE64 padding.

#include <windows.h>
#include "sodium.h" // For generating random bytes.
#include "openssl/sha.h" // For generating SHA256 hash.
#include "wincrypt.h" // For encoding with BASE64.
#pragma comment(lib, "crypt32.lib") // For encoding with BASE64.
#include "CodeChallengeHelper.h"
using namespace std;
string CodeChallengeHelper::verifier = "";
void strFindAndReplace(std::string& str,
    const std::string& oldStr,
    const std::string& newStr)
{
    std::string::size_type pos = 0u;
    while ((pos = str.find(oldStr, pos)) != std::string::npos) {
        str.replace(pos, oldStr.length(), newStr);
        pos += newStr.length();
    }
}
static inline void trimPadding(std::string& s) {
    s.erase(std::find_if(s.rbegin(), s.rend(), [](unsigned char ch) {
        return ch != '=';
        }).base(), s.end());
}

We used third party libraries in the code above. You will need to download and install them to your Windows app. Using the vcpkg package manager is the best way to download and install them.

Download and install vcpkg.

git clone https://github.com/Microsoft/vcpkg.git
cd vcpkg
./bootstrap-vcpkg.sh
./vcpkg integrate install

Install third party libraries with vcpkg.

./vcpkg install libsodium
./vcpkg install openssl

When using the CodeChallengeHelper, call createCodeVerifier to create a verifier before accessing the verifier field or the return value of getCodeChallenge.

Send a request to authentication server

After you've created the challenge, include it as a query parameter on the request to the authorization server.

string url_encode(const string& value) {
    ostringstream escaped;
    escaped.fill('0');
    escaped << hex;
    for (string::const_iterator i = value.begin(), n = value.end(); i != n; ++i) {
        string::value_type c = (*i);
        // Keep alphanumeric and other accepted characters intact
        if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
            escaped << c;
            continue;
        }
        // Any other characters are percent-encoded
        escaped << uppercase;
        escaped << '%' << setw(2) << int((unsigned char)c);
        escaped << nouppercase;
    }
    return escaped.str();
}
string getAuthURL()
{
    std::string url = "https://zoom.us/oauth/authorize";
    url += "?response_type=code";
    url += "&client_id=" + CLIENT_ID or public client ID;
    url += "&code_challenge=" + CodeChallengeHelper::getCodeChallenge();
    url += "&redirect_uri=" + url_encode(REDIRECT_URI);
    url += "&code_challenge_method=" + string("S256");
    return url;
}
private String getAuthURL()
{
    var baseURL = new Uri("https://zoom.us/oauth/authorize");
    var queryStrings = HttpUtility.ParseQueryString(baseURL.Query);
    queryStrings.Add("response_type", "code");
    queryStrings.Add("client_id", CLIENT_ID); // public client ID from your Marketplace app
    queryStrings.Add("code_challenge", CodeChallengeHelper.getCodeChallenge(CodeChallengeHelper.verifier));
    queryStrings.Add("redirect_uri", REDIRECT_URI);
    queryStrings.Add("code_challenge_method", "S256");
    var uriBuilder = new UriBuilder(baseURL);
    uriBuilder.Query = queryStrings.ToString();
    return uriBuilder.Uri.ToString();
}

Launch the URL in a browser to allow the user to authenticate

Finally, create a Windows process to allow the user to authenticate through the default browser.

#include <shellapi.h>
void launchBrowserForAuth(){
    std::string oAuthUrl = getAuthURL();
    ShellExecuteW(NULL, L"open", std::wstring(oAuthUrl.begin(), oAuthUrl.end()).c_str(), NULL, NULL, SW_SHOW);
}
var oAuthUrl = getAuthURL();
var ps = new ProcessStartInfo(oAuthUrl)
{
    UseShellExecute = true,
    Verb = "open"
};
Process.Start(ps);

Handle the response

After your app executes the request in the previous step, the user will see the Zoom login in a browser. Once they've logged in and consented to your app's scopes, the Windows Desktop Application you set up earlier in the Setup URI Scheme step will be started. From here, you will need to exchange the authorization code for an access token and include the code verifier to prove that you were the one who initiated the authorization.

#include "cpr/cpr.h"
#include "json/json.h"
string requestAccessToken(string code)
{
  cpr::Response r = cpr::Post(
      cpr::Url{ "https://zoom.us/oauth/token" },
      cpr::Authentication{ CLIENT_ID, CLIENT_SECRET, cpr::AuthMode::BASIC },
      cpr::Payload{
          {"grant_type", "authorization_code"},
          {"code", code},
          {"redirect_uri", REDIRECT_URI},
          {"code_verifier", CodeChallengeHelper::verifier}
      });
    if (r.status_code == 200) {
        Json::Value root;
        Json::Reader reader;
        if (reader.parse(r.text, root))
            return root.get("access_token", "").asString();
    }
    return string();
}

Two third party libraries are used in the code. Install them with vcpkg.

./vcpkg install cpr
./vcpkg install jsoncpp
public class AccessTokenResponse
  {
      public String access_token { get; set; }
      public String token_type { get; set; }
      public String refresh_token { get; set; }
      public long expires_in { get; set; }
      public String scope { get; set; }
  }
  private async Task<String> requestAccessToken(String code)
  {
      var requestMessage = new HttpRequestMessage(HttpMethod.Post, "https://zoom.us/oauth/token");
      var encoded_credential = Convert.ToBase64String(
          Encoding.ASCII.GetBytes(CLIENT_ID + ":" + CLIENT_SECRET));
      requestMessage.Headers.Authorization =
          new AuthenticationHeaderValue("Basic", encoded_credential);
      requestMessage.Content = new FormUrlEncodedContent(new[]
      {
          new KeyValuePair<string, string>("grant_type", "authorization_code"),
          new KeyValuePair<string, string>("code", code),
          new KeyValuePair<string, string>("redirect_uri", REDIRECT_URI),
          new KeyValuePair<string, string>("code_verifier", CodeChallengeHelper.verifier)
      });
      var response = await client.SendAsync(requestMessage);
      var accessTokenResponse = await response.Content.ReadFromJsonAsync<AccessTokenResponse>();
      return accessTokenResponse?.access_token ?? "";
  }

To get the code used as the code query param, parse it from the URL used to redirect to your app. These snippets rely on credentials obtained from the Zoom Marketplace.

  string parseCode(std::string dataUri)
  {
      size_t queryStart = dataUri.find('?');
      if (queryStart == std::string::npos)
          return string();
      string queryString = dataUri.substr(queryStart + 1);
      // Parse all query params
      std::map<string, string> params;
      std::istringstream stream(queryString);
      string pair;
      while (std::getline(stream, pair, '&'))
      {
          size_t eq = pair.find('=');
          if (eq != string::npos)
              params[pair.substr(0, eq)] = pair.substr(eq + 1);
      }
      if (params.count("error"))
      {
          handleAuthError(params["error"]);
          return string();
      }
      if (params.count("code"))
          return params["code"];
      return string();
  }
  int main(int argc, char** argv)
  {
      if (argc > 1)
      {
          std::string code = parseCode(argv[1]);
      }
      // ...
  }
private async Task<String> parseCode(String dataUri)
  {
      var uri = new Uri(dataUri);
      var queryStrings = HttpUtility.ParseQueryString(uri.Query);
      var error = queryStrings.Get("error");
      if (error != null)
      {
          handleAuthError(error);
          return "";
      }
      var code = queryStrings.Get("code");
      return code ?? "";
  }
private async void Form1_Load(object sender, EventArgs e)
{
    string[] args = Environment.GetCommandLineArgs();
    if (args.Length > 1)
    {
        var code = await parseCode(args[1]);
    }
}

The returned access_token is a user-level OAuth 2.0 Bearer token. Send it on Zoom REST API requests as Authorization: Bearer <access_token>. It will only be able to call endpoints covered by the scopes granted to your Marketplace app.