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
- In the Zoom Marketplace, create a general app, assign scopes for the APIs to call, and note its Public Client ID in App Credentials.
- For Power BI Desktop, create a
.mfile 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.mezfile, but it seems to prevent the Data Sources user interface from cataloging it.) - Create a blank query for your target API, for example:
PersonalZoomAnalytics.Contents("https://api.zoom.us/v2/users/me") - In the “Please specify how to connect.” banner, pick Edit Credentials and follow the prompts to authorize the application.
- 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])
);