Token Revocation for OAuth PKCE using Public Client ID

API Endpoint

/oauth/revoke

Description

Is token revocation supported for User OAuth with Proof Key for Code Exchange (PKCE) using the public client ID? I’m experimenting with getting Zoom data using Power Query by writing my first custom connector (copy and paste quality level!) and trying to properly handle token revocation, but the OAuth official documentation doesn’t specifically call out how to do this for PKCE.

Error Message

Attempting to revoke the token by passing the client_id in the request body (the same way it is done in the authorization phase for a public client ID) yields a 400 Bad Request error with the response body:

{
    "reason": "Invalid client_id or client_secret",
    "error": "invalid_client"
}

How To Reproduce

  1. In the Zoom Marketplace, create a general app, assign scopes for the APIs to call, and note its Public Client ID in App Credentials.
  2. For Power BI Desktop, create a .m file in the Custom Connectors folder and populate its contents with the connector code following these steps (inserting the public client ID into the code). (This is an unpackaged custom connector, which makes it easier to iterate without needing a build process to produce a .mez file, but it seems to prevent the Data Sources user interface from cataloging it.)
  3. Create a blank query for your target API, for example: PersonalZoomAnalytics.Contents("https://api.zoom.us/v2/users/me")
  4. In the “Please specify how to connect.” banner, pick Edit Credentials and follow the prompts to authorize the application.
  5. Go to Data Source Settings, find the target API you chose, and pick Edit Permissions…, Edit…, Sign in as different user to log out.

Power Query Connector Code

section PersonalZoomAnalytics;

client_id = "(insert Public Client ID)";
endpoint_domain = "zoom.us";
OAuthcode_challenge_method = "S256";
redirect_uri = "https://oauth.powerbi.com/views/oauthredirect.html";
windowWidth = 720;
windowHeight = 1024;

[DataSource.Kind = "PersonalZoomAnalytics"]
shared PersonalZoomAnalytics.RawResponse = Value.ReplaceType(PersonalZoom.RawResponse, type function (url as Uri.Type) as binary);

[DataSource.Kind = "PersonalZoomAnalytics"]
shared PersonalZoomAnalytics.RawContents = Value.ReplaceType(PersonalZoom.RawContents, type function (url as Uri.Type) as binary);

[DataSource.Kind = "PersonalZoomAnalytics", Publish = "PersonalZoomAnalytics.UI"]
shared PersonalZoomAnalytics.Contents = Value.ReplaceType(PersonalZoom.Contents, type function (url as Uri.Type) as any);

[DataSource.Kind = "PersonalZoomAnalytics"]
shared PersonalZoomAnalytics.PagedTable = Value.ReplaceType(
    PersonalZoom.PagedTable, type function (url as Uri.Type, listPropertyName as (type text meta [DataSource.Path = false])) as nullable table
);

PersonalZoomAnalytics = [
    TestConnection = (dataSourcePath) => {"PersonalZoomAnalytics.RawContents", dataSourcePath},
    Authentication = [
        OAuth = [
            StartLogin = StartLogin,
            FinishLogin = FinishLogin,
            Refresh = Refresh,
            Logout = Logout,
            Label = "Zoom User via OAuth"
        ]
    ]
];

PersonalZoomAnalytics.UI = [
    Beta = true,
    ButtonText = {"Personal Zoom Analytics", "Retrieve data from the Zoom API using OAuth authentication."}
];

PersonalZoom.RawResponse = (url as text) =>
    let
        content = Web.Contents(url, [ManualStatusHandling = {400, 401, 403, 404, 429, 500}])
    in
        content;

PersonalZoom.RawContents = (url as text) =>
    let
        content = Web.Contents(url)
    in
        content;

PersonalZoom.Contents = (url as text) =>
    let
        content = Web.Contents(url),
        json = Json.Document(content)
    in
        json;

PersonalZoom.PageContents = (url as text, listPropertyName as text) =>
    let
        content = Web.Contents(url),
        json = Json.Document(content),
        link = GetNextLink(url, json),
        table = Table.FromList(Record.Field(json, listPropertyName), Splitter.SplitByNothing())
    in
        table meta [Next = link];

PersonalZoom.PagedTable = (url as text, listPropertyName as text) =>
    Table.GenerateByPage(
        (previous) =>
            let
                next = if (previous <> null) then Value.Metadata(previous)[Next] else null,
                urlToUse = if (next <> null) then next else url,
                current = if (previous <> null and next = null) then null else PersonalZoom.PageContents(urlToUse, listPropertyName),
                link = if (current <> null) then Value.Metadata(current)[Next] else null
            in
                current meta [Next = link]
    );

GetNextLink = (url, json) =>
    let
        next_page_token = json[#"next_page_token"]?,
        request_address_parts = Uri.Parts(url),
        request_address_query = request_address_parts[#"Query"],
        next_address_query_base = if Record.HasFields(request_address_query, "next_page_token") then request_address_query else Record.AddField(request_address_query, "next_page_token", ""),
        next_address_query = Record.TransformFields(next_address_query_base, {"next_page_token", each next_page_token}),
        next_address_parts = Record.TransformFields(request_address_parts, {"Query", each next_address_query}),
        next_address = Text.BeforeDelimiter(url, "?") & "?" & Uri.BuildQueryString(next_address_query)
    in
        if ((next_page_token <> null) and (next_page_token <> "")) then try next_address otherwise null else null;

StartLogin = (resourceUrl, state, display) =>
    let
        plainTextCodeVerifier = Text.NewGuid() & Text.NewGuid(),
        codeVerifier =
            if (OAuthcode_challenge_method = "plain") then
                plainTextCodeVerifier
            else if (OAuthcode_challenge_method = "S256") then
                Base64Url.Encode(Crypto.CreateHash(CryptoAlgorithm.SHA256, Text.ToBinary(plainTextCodeVerifier)))
            else
                error "Unexpected code_challenge_method:  " & OAuthcode_challenge_method,
        AuthorizeUrl = "https://" & endpoint_domain & "/oauth/authorize?"
            & Uri.BuildQueryString(
                [
                    client_id = client_id,
                    response_type = "code",
                    code_challenge_method = OAuthcode_challenge_method,
                    code_challenge = codeVerifier,
                    state = state,
                    redirect_uri = redirect_uri
                ]
            )

    in
        [
            LoginUri = AuthorizeUrl,
            CallbackUri = redirect_uri,
            WindowHeight = windowHeight,
            WindowWidth = windowWidth,
            Context = plainTextCodeVerifier
        ];

FinishLogin = (context, callbackUri, state) => let Parts = Uri.Parts(callbackUri)[Query] in TokenMethod(Parts[code], "authorization_code", context);

TokenMethod = (code, grant_type, optional verifier) =>
    let
        codeVerifier = if (verifier <> null) then [code_verifier = verifier] else [],
        codeParameter = if (grant_type = "authorization_code") then [code = code] else [refresh_token = code],
        query = codeVerifier
            & codeParameter
            & [
                client_id = client_id,
                grant_type = grant_type,
                redirect_uri = redirect_uri
            ],
        ManualHandlingStatusCodes = {},

        Response = Web.Contents(
            "https://" & endpoint_domain & "/oauth/token",
            [
                Content = Text.ToBinary(
                    Uri.BuildQueryString(query), TextEncoding.Utf8, false
                ),
                Headers = [#"Content-Type" = "application/x-www-form-urlencoded; charset=utf-8", #"Accept" = "application/json"],
                ManualStatusHandling = ManualHandlingStatusCodes
            ]
        ),
        Parts = Json.Document(Response)
    in
        if (Parts[error]? <> null) then
            error Error.Record(Parts[error], Parts[message]?)
        else
            Parts;

Refresh = (resourceUrl, refresh_token) => TokenMethod(refresh_token, "refresh_token");

Logout = (accessToken) => 
    let
        query = if accessToken <> null then [
                client_id = client_id,
                token = accessToken
            ] else null,
        ManualHandlingStatusCodes = {},
        Response = if query <> null then Web.Contents(
            "https://" & endpoint_domain & "/oauth/revoke",
            [
                Content = Text.ToBinary(Uri.BuildQueryString(query), TextEncoding.Utf8, false),
                Headers = [
                    #"Content-Type" = "application/x-www-form-urlencoded; charset=utf-8",
                    #"Accept" = "application/json"
                ],
                ManualStatusHandling = ManualHandlingStatusCodes
            ]
        ) else null,
        Parts = if Response <> null then Json.Document(Response) else null
    in
        "https://" & endpoint_domain & "/logout" & (if Parts <> null then "" else "");

Base64Url.Encode = (s) => Text.Replace(Text.Replace(Text.BeforeDelimiter(Binary.ToText(s,BinaryEncoding.Base64),"="),"+","-"),"/","_");

Table.GenerateByPage = (getNextPage as function) as table =>
    let
        listOfPages = List.Generate(
            () => getNextPage(null), (lastPage) => lastPage <> null, (lastPage) => getNextPage(lastPage)
        ),
        tableOfPages = Table.FromList(listOfPages, Splitter.SplitByNothing(), {"Column1"}),
        firstRow = tableOfPages{0} ?
    in
        if (firstRow = null) then
            Table.FromRows({})
        else
            Value.ReplaceType(
                Table.ExpandTableColumn(tableOfPages, "Column1", Table.ColumnNames(firstRow[Column1])),
                Value.Type(firstRow[Column1])
            );