ZoomNet icon indicating copy to clipboard operation
ZoomNet copied to clipboard

Unauthorized status code on CloudRecordings.DownloadFileAsync invocation.

Open pvgritsenko-ansible opened this issue 1 year ago • 6 comments

Sometimes when we try to download Zoom recording file we encounter following Pathoschild.Http.Client.ApiException with status code 'Unauthorized'. The only idea is access token expired and for some reasone it was not successfuly updated by OAuthTokenHandler.

And may it be possible that the source of issue is ZoomRetryCoordinator.ExecuteAsync method where we check the responseContent.message contains "access token is expired" string. I've simulated the exception and noted that actually message is empty. Can it be empty just because we cannot read stream content?

Full stack-trace:

Exception: Pathoschild.Http.Client.ApiException: The API query failed with status code Unauthorized: Unauthorized
   at Pathoschild.Http.Client.Extensibility.DefaultErrorFilter.OnResponse(IResponse response, Boolean httpErrorAsException)
   at Pathoschild.Http.Client.Internal.Request.Execute()
   at Pathoschild.Http.Client.Internal.Request.AsStream()
   at ZoomNet.Resources.CloudRecordings.DownloadFileAsync(String downloadUrl, CancellationToken cancellationToken)

pvgritsenko-ansible avatar Jul 02 '24 18:07 pvgritsenko-ansible

Are you able to capture the response from Zoom (using a tool such as Fiddler for example)? This would allow us to validate your theory rather than just guessing.

Jericho avatar Jul 03 '24 11:07 Jericho

I wrote a unit test to simulate the scenario in your theory and I am not able to reproduce the problem, the unit test completes successfully. This seems to indicate that the problem you are experiencing is not related to an expired token (or that my unit test does not reflect your scenario accurately).

/// <summary>
/// This unit test simulates a scenario where we attempt to download a file but our oAuth token has expired.
/// In this situation, we expect the token to be refreshed and the download request to be reissued.
/// </summary>
/// <returns></returns>
[Fact]
public async Task DownloadFileAsync_with_expired_token()
{
	// Arrange
	var downloadUrl = "http://dummywebsite.com/dummyfile.txt";

	var mockTokenHttp = new MockHttpMessageHandler();
	mockTokenHttp // Issue a new token
		.When(HttpMethod.Post, "https://api.zoom.us/oauth/token")
		.Respond(HttpStatusCode.OK, "application/json", "{\"refresh_token\":\"new refresh token\",\"access_token\":\"new access token\"}");

	var mockHttp = new MockHttpMessageHandler();
	mockHttp // The first time the file is requested, we return "401 Unauthorized" to simulate an expired token.
		.Expect(HttpMethod.Get, downloadUrl)
		.Respond(HttpStatusCode.Unauthorized, new StringContent("{\"message\":\"access token is expired\"}"));
	mockHttp // The second time the file is requested, we return "200 OK" with the file content.
		.Expect(HttpMethod.Get, downloadUrl)
		.Respond(HttpStatusCode.OK, new StringContent("This is the content of the file"));

	var client = Utils.GetFluentClient(mockHttp, mockTokenHttp);
	var recordings = new CloudRecordings(client);

	// Act
	var result = await recordings.DownloadFileAsync(downloadUrl, CancellationToken.None).ConfigureAwait(true);

	// Assert
	mockHttp.VerifyNoOutstandingExpectation();
	mockHttp.VerifyNoOutstandingRequest();
	result.ShouldNotBeNull();
}

Is it possible that the Unauthorized response is legitimate and you are simply not authorized to download this file? I know that Zoom documentation mentions something about an access token specific to downloading file and different than your own access token:

If a user has authorized and installed your OAuth app that contains recording scopes, use the download_access_token or the the user's OAuth access token to download the file, and set the access_token as a Bearer token in the Authorization header.

ZoomNet currently does not support this alternate token and always uses the token that was obtained when initiating your OAuth session. Maybe this is the scenario you are facing? Maybe your access token is not sufficient to download this file? I'm just speculating.

Jericho avatar Jul 03 '24 14:07 Jericho

@pvgritsenko-ansible are you still interested in researching this problem? In case I wasn't clear, let me reiterate that I was not able to reproduce and therefore I need more information from you to continue researching. Capturing the payload you receive from Zoom would be a great first step. Also, I shared with you some hypothesis. Do they make sense? Do you have any feedback?

Jericho avatar Jul 12 '24 13:07 Jericho

@pvgritsenko-ansible are you still interested in researching this problem? In case I wasn't clear, let me reiterate that I was not able to reproduce and therefore I need more information from you to continue researching. Capturing the payload you receive from Zoom would be a great first step. Also, I shared with you some hypothesis. Do they make sense? Do you have any feedback?

Hi, @Jericho yes, I still try to find the source of problem. Sorry to don't keep you up to date. It's hard to capture the Zoom response since the issue for some reasone does't reproduce on dev machines (only in PROD env where we can't setup any additional apps). But I'm going to test another way to simulate this problem. I'll write to you this week and report results.

About your hypothesis. It is possible, but we've downloaded files before using a general access token. It is also strange that in this case the issue occurs only with some files, and not with all.

pvgritsenko-ansible avatar Jul 15 '24 14:07 pvgritsenko-ansible

Is this problem consistent with a given file? What I mean is: do you get this error consistently when you attempt to download a certain file or does this problem go away after a certain amount of time? If the problem goes away, maybe it gives credence to your original hypothesis this it's related to an expired token.

However, if the problem is consistent, maybe it points to the fact that there's some additional security around this file and it prevents you from downloading it. And if this is the case, maybe we need to invest time and effort to support the download_access_token I mentioned previously.

Jericho avatar Jul 16 '24 13:07 Jericho

We try do download some transcript file periodically. And usually we don't have such exception. But sometimes it occurs. Our simplified workflow looks like:

  1. Complete some meeting with recordings.
  2. Wait for "recording.transcript_completed" messages on our webhook and extract meeting instance UUID from message payload
  3. Check the files really prepared to download (sometimes they are not prepared and we need to periodically poll file statuses until they are completed) using ZoomClient.CloudRecordings.GetRecordingInformationAsync
  4. When the files completed we try to download them using DownloadUrl that we have taken from ZoomClient.CloudRecordings.GetRecordingInformationAsync method response

And sometimes at the step 4 we receive Unauthorized exception.

pvgritsenko-ansible avatar Jul 17 '24 16:07 pvgritsenko-ansible

There is an update. I found following issue thread on the Zoom dev forum that referenced to the same problem: https://devforum.zoom.us/t/finding-the-value-for-download-access-token-for-the-get-meeting-recordings-api-endpoint/109685/9

And unfortunately it looks like the source of issue on the Zoom side. In our project we will try to use download_access_token instead of common access_token. But I'm not sure that it will help us. I'll post some comment if I have any updates on this story...

pvgritsenko-ansible avatar Aug 01 '24 13:08 pvgritsenko-ansible

The download_access_token is something I mentioned to you a few weeks ago:

I know that Zoom documentation mentions something about an access token specific to downloading file and different than your own access token:

If a user has authorized and installed your OAuth app that contains recording scopes, use the download_access_token or the the user's OAuth access token to download the file, and set the access_token as a Bearer token in the Authorization header.

ZoomNet currently does not support this alternate token and always uses the token that was obtained when initiating your OAuth session. Maybe this is the scenario you are facing? Maybe your access token is not sufficient to download this file? I'm just speculating.

And in a subsequent comment I said:

if the problem is consistent, maybe it points to the fact that there's some additional security around this file and it prevents you from downloading it. And if this is the case, maybe we need to invest time and effort to support the download_access_token I mentioned previously.

Let me know if your testing with this download_access_token is conclusive. If so, we can look into enhancing ZoomNet to support it.

Jericho avatar Aug 01 '24 14:08 Jericho

Yes, I've read your comments before. That's one of the reason why we'll try to use download_access_token. I'll prepare a little PR with Recording model and related cloud recordings endpoint update to include this token to response.

pvgritsenko-ansible avatar Aug 01 '24 14:08 pvgritsenko-ansible

I've prepared a beta NuGet package for you with two improvements:

  • CloudRecordings.GetRecordingInformationAsync has been enhanced to return the download_access_token. Also, when you invoke this method you can specify the "time to live" for the token (in seconds).
  • CloudRecordings.DownloadFileAsync has been enhanced to accept an optional token. If you omit this value, the token for your current OAuth session will be used which matches the current behavior.

This package is called 'ZoomNet 0.80.0-download-access-0018' and it's available on my personal NuGet feed (instructions).

Let me know if this helps.

Jericho avatar Aug 01 '24 19:08 Jericho

Thanks a lot! I'll check it

pvgritsenko-ansible avatar Aug 02 '24 13:08 pvgritsenko-ansible

I have tested 'ZoomNet 0.80.0-download-access-0018'. The GetRecordingInformationAsync method works well.

But it seems that something is wrong with DownloadFileAsync. I created a ZoomClient with an expired token using the constructor. Then, using Postman, I got a valid access_token and passed it as an argument to the 'DownloadFileAsync' method. But the method returned me an 'Unauthorized' exception. After that, through Postman, I got the 'download token' and tried to use it as an argument. The result was the same.

Maybe I missed something?

pvgritsenko-ansible avatar Aug 05 '24 15:08 pvgritsenko-ansible

Can you please provide code snippet to help me reproduce

Jericho avatar Aug 05 '24 15:08 Jericho

I created a ZoomClient with an expired token using the constructor.

I'm guessing you're using an expired token because you want to see if ZoomNet will refresh this expired token.

Then, using Postman, I got a valid access_token and passed it as an argument to the 'DownloadFileAsync' method.

You have previously established that Zoom is rejecting your OAuth token when you attempt to download a file. If your OAuth token is not valid to download a file, I highly doubt that the tool you use to get a refreshed token will change anything. I don't think Zoom cares that use used Postman or any other tool to renew your token. You token is not valid, period. Therefore, I'm not surprised that Zoom is rejecting this OAuth token.

After that, through Postman, I got the 'download token' and tried to use it as an argument. The result was the same.

The improvement I made to GetRecordingInformationAsync includes the download token that you should use to download the file, therefore you shouldn't need to use Postman for this.

your code should look something like this:

// The Id of the meeting
var meetingId = 123;

// Use a ttl that seems reasonable to you. In this example, I'm using 5 minutes.
const int ttl = 60 * 5;

// Get recording information for the meeting. This includes the download_access_token
var recordingInfo = await client.CloudRecordings.GetRecordingInformationAsync(meetingId, ttl, cancellationToken).ConfigureAwait(false);

// This is the new property added in the beta NuGet package
var downloadToken = recordingInfo.DownloadAccessToken;

// Download all files for the meeting. Don't forget to specify the download token when invoking the DownloadFileAsync method
foreach (var recordingFile in recordingInfo.RecordingFiles)
{
    var stream = await client.CloudRecordings.DownloadFileAsync(recordingFile.DownloadUrl, downloadToken, cancellationToken).ConfigureAwait(false);
}

Having said that, if you are still getting the 'Unauthorized' exception, I think it demonstrate that the token is probably not the source of this problem. I think it's time for you to escalate this problem to Zoom support and get guidance from them.

Jericho avatar Aug 05 '24 16:08 Jericho

I just wanted to check that when I use the 'DownloadFileAsync' method with a download token, this token will be used even if the access token has expired. My workflow:

  1. Get OAuth token from Zoom using Postman.
  2. Wait for token expiration
  3. Create ZoomNet.ZoomClient with expired token parameter.
  4. Generate new access token using Postman
  5. Get Zoom Recordings info using Postman. Retrieve download_token and download_url from given info
  6. Call ZoomClient.CloudRecordings.DownloadFileAsync(download_url, download_token). Here I get an Unauthorized exception, which indicates that access_token is used to download file, with which ZoomClient was created.

If I create new ZoomClient without token argument and call ZoomClient.CloudRecordings.DownloadFileAsync(download_url, download_token), it will work as expected and files will be downloaded. No metter how I retrieved download_url and download_token, by Postman or ZoomClient.CloudRecordings.GetRecordingInformationAsynce. I'm not sure if this will help, but here is the code with mockup data that describes what I did:

        private ZoomClient CreateZoomClient(string token = null)
        {
            var connectionInfo = OAuthConnectionInfo.ForServerToServer(
                "MyClientId",
                "MyClientSecret",
                "MyAccountId",
                accessToken: token);

            return new ZoomClient(connectionInfo);
        }
        
        public async Task<Stream> TestMethod()
        {
		ZoomClient _zoomClient = CreateZoomClient("Expired_token");
		try
		{
			return await _zoomClient.CloudRecordings.DownloadFileAsync("download_url_given_from_postman", "download_token_given_from_postman");
		}
		catch (Pathoschild.Http.Client.ApiException apiException)
		{
                        if (apiException.Status != System.Net.HttpStatusCode.Unauthorized)
                        {
                               // Here I get an exception.
                        }
		}
        }

And actually we already on the way to start the Zoom support thread.

pvgritsenko-ansible avatar Aug 05 '24 17:08 pvgritsenko-ansible

  1. Call ZoomClient.CloudRecordings.DownloadFileAsync(download_url, download_token). Here I get an Unauthorized exception, which indicates that access_token is used to download file, with which ZoomClient was created.

I didn't think about the fact that the OAuth handler in our library (here) overrides the custom token. Let me fix that and I'll publish a new beta package.

I'm not sure if this will help, but here is the code with mockup data that describes what I did

Yes, it's always helpful. Thank you for providing this.

Jericho avatar Aug 05 '24 20:08 Jericho

Beta package 0.80.0-download-access-token.1-20 published

Jericho avatar Aug 06 '24 01:08 Jericho

Thanks, it works as expected. I will integrate this version of ZoomNet into my project and monitor for "unauthorized" exceptions.

pvgritsenko-ansible avatar Aug 06 '24 07:08 pvgritsenko-ansible

:tada: This issue has been resolved in version 0.80.0 :tada:

The release is available on:

Your GitReleaseManager bot :package::rocket:

Jericho avatar Aug 07 '24 16:08 Jericho