Skip to content

Commit

Permalink
Merge branch 'release/0.44.0'
Browse files Browse the repository at this point in the history
  • Loading branch information
Jericho committed Jun 30, 2022
2 parents 88ffa3a + 036b391 commit c547085
Show file tree
Hide file tree
Showing 37 changed files with 861 additions and 366 deletions.
66 changes: 60 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,33 +56,87 @@ var apiSecret = "... your API secret ...";
var connectionInfo = new JwtConnectionInfo(apiKey, apiSecret);
```

> **Warning:** <a href="https://marketplace.zoom.us/docs/guides/build/jwt-app/jwt-faq/">Zoom has announced</a> that this authentication method would be obsolete in June 2023. The recommendation is to swith to Server-to-Server OAuth.

#### Connection using OAuth
Using OAuth is much more complicated than using JWT but at the same time, it is more flexible because you can define which permissions your app requires. When a user installs your app, they are presented with the list of permissions your app requires and they are given the opportunity to accept.

The Zoom documentation has a document about [how to create an OAuth app](https://marketplace.zoom.us/docs/guides/build/oauth-app) and another document about the [OAuth autorization flow](https://marketplace.zoom.us/docs/guides/auth/oauth) but I personnality was very confused by the later document so here is a brief step-by-step summary:
- you create an OAuth app, define which permissions your app requires and publish the app in the Zoom marketplace.
- user installs your app. During installation, user is presentd with a screen listing the permissons your app requires. User must click `accept`.
- Zoom generates a "authorization code". This code can be used only once to generate the first access token and refresh token. I CAN'T STRESS THIS ENOUGH: the authorization code can be used only one time. This was the confusing part to me: somehow I didn't understand that this code could be used only one time and I was attempting to use it repeatedly. Zoom would accept the code one time and would reject it subsequently, which lead to many hours of frustration while trying to figure out why the code was sometimes rejected.
- Zoom generates a "authorization code". This code can be used only once to generate the first access token and refresh token. I CAN'T STRESS THIS ENOUGH: the authorization code can be used only one time. This was the confusing part to me: somehow I didn't understand that this code could be used only one time and I was attempting to use it repeatedly. Zoom would accept the code the first time and would reject it subsequently, which lead to many hours of frustration while trying to figure out why the code was sometimes rejected.
- The access token is valid for 60 minutes and must therefore be "refreshed" periodically.

ZoomNet takes care of generating the access token and refresh token but it's your responsability to store these generated values.
ZoomNet takes care of generating the access token and the refresh token but it's your responsability to store these generated values.

```csharp
var clientId = "... your client ID ...";
var clientSecret = "... your client secret ...";
var refreshToken = "... the refresh token previously issued by Zoom ...";
var accessToken = "... the access token previously issued by Zoom ...";
var connectionInfo = new OAuthConnectionInfo(clientId, clientSecret, refreshToken, accessToken,
(newRefreshToken, newAccessToken) =>
{
/*
(newRefreshToken, newAccessToken) =>
{
/*
Save the new refresh token and the access token to
a safe place so you can provide it the next time
you need to instantiate an OAuthConnectionInfo
you need to instantiate an OAuthConnectionInfo.
*/
});
```

For demonstration purposes, here's how you could use your operating system's environment variables to store the tokens

```csharp
var clientId = "... your client ID ...";
var clientSecret = "... your client secret ...";
var refreshToken = Environment.GetEnvironmentVariable("ZOOM_OAUTH_REFRESHTOKEN", EnvironmentVariableTarget.User);
var accessToken = Environment.GetEnvironmentVariable("ZOOM_OAUTH_ACCESSTOKEN", EnvironmentVariableTarget.User);
var connectionInfo = new OAuthConnectionInfo(clientId, clientSecret, refreshToken, accessToken,
(newRefreshToken, newAccessToken) =>
{
Environment.SetEnvironmentVariable("ZOOM_OAUTH_REFRESHTOKEN", newRefreshToken, EnvironmentVariableTarget.User);
Environment.SetEnvironmentVariable("ZOOM_OAUTH_ACCESSTOKEN", newAccessToken, EnvironmentVariableTarget.User);
});
```

#### Connection using Server-to-Server OAuth

This authentication method is the replacement for JWT authentication which Zoom announced will be made obsolete in June 2023.

From Zoom's documentation:
> A Server-to-Server OAuth app enables you to securely integrate with Zoom APIs and get your account owner access token without user interaction. This is different from the OAuth app type, which requires user authentication. See Using OAuth 2.0 for details.
ZoomNet takes care of getting a new access token and it also refreshes a previously issued token when it expires (Server-to-Server access token are valid for one hour).
Therefore

```csharp
var clientId = "... your client ID ...";
var clientSecret = "... your client secret ...";
var accountId = "... your account id ...";
var connectionInfo = new OAuthConnectionInfo(clientId, clientSecret, accountId,
(_, newAccessToken) =>
{
/*
Server-to-Server OAuth does not use a refresh token. That's why I used '_' as the first parameter
in this delegate declaration. Furthermore, ZoomNet will take care of getting a new access token
and to refresh it whenever it expires therefore there is no need for you to preserve the token
like you must do for the 'standard' OAuth authentication.
In fact, this delegate is completely optional when using Server-to-Server OAuth. Feel free to pass
a null value in lieu of a delegate.
*/
});
```

The delegate being optional in the server-to-server scenario you can therefore simplify the connection info declaration like so:

```csharp
var connectionInfo = new OAuthConnectionInfo(clientId, clientSecret, accountId, null);
```


### Client

You declare your client variable like so:
Expand Down
37 changes: 35 additions & 2 deletions Source/ZoomNet.IntegrationTests/Tests/Chat.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
Expand Down Expand Up @@ -46,14 +47,46 @@ public async Task RunAsync(User myUser, string[] myPermissions, IZoomClient clie
await log.WriteLineAsync($"Account channel \"{channel.Id}\" has {paginatedMembers.TotalRecords} members").ConfigureAwait(false);

// SEND A MESSAGE TO THE CHANNEL
var messageId = await client.Chat.SendMessageToChannelAsync(channel.Id, "This is a test from integration test", null, cancellationToken).ConfigureAwait(false);
var messageId = await client.Chat.SendMessageToChannelAsync(channel.Id, "This is a test from integration test", null, null, null, cancellationToken).ConfigureAwait(false);
await log.WriteLineAsync($"Message \"{messageId}\" sent").ConfigureAwait(false);
await Task.Delay(1000, cancellationToken).ConfigureAwait(false); // Allow the Zoom system to process this message and avoid subsequent "message doesn't exist" error messages

// UPDATE THE MESSAGE
await client.Chat.UpdateMessageToChannelAsync(messageId, channel.Id, "This is an updated message from integration testing", null, cancellationToken).ConfigureAwait(false);
await client.Chat.UpdateMessageToChannelAsync(messageId, channel.Id, "This is an updated message from integration testing.\nThis message contains simple text.", null, cancellationToken).ConfigureAwait(false);
await log.WriteLineAsync($"Message \"{messageId}\" updated").ConfigureAwait(false);

// REPLY TO THE MESSAGE
messageId = await client.Chat.SendMessageToChannelAsync(channel.Id, "This is a reply to the message.", messageId, null, null, cancellationToken).ConfigureAwait(false);
await log.WriteLineAsync($"Reply \"{messageId}\" sent").ConfigureAwait(false);
await Task.Delay(1000, cancellationToken).ConfigureAwait(false); // Allow the Zoom system to process this message and avoid subsequent "message doesn't exist" error messages

// Check that this computer has a folder containing sample images which we can use to send files to the channel
var samplePicturesFolder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyPictures), "Samples");
if (Directory.Exists(samplePicturesFolder))
{
var rnd = new Random();
var samplePictures = Directory.EnumerateFiles(samplePicturesFolder, "*.jpg");
if (samplePictures.Any())
{
// SEND A FILE TO THE CHANNEL
var samplePicture = samplePictures.ElementAt(rnd.Next(0, samplePictures.Count()));
using var fileToSendStream = File.OpenRead(samplePicture);
var sentFileId = await client.Chat.SendFileAsync(null, "me", null, channel.Id, Path.GetFileName(samplePicture), fileToSendStream, cancellationToken).ConfigureAwait(false);
await log.WriteLineAsync($"File {sentFileId} sent").ConfigureAwait(false);

// UPLOAD A FILE
samplePicture = samplePictures.ElementAt(rnd.Next(0, samplePictures.Count()));
using var fileToUploadStream = File.OpenRead(samplePicture);
var uploadedFileId = await client.Chat.UploadFileAsync("me", Path.GetFileName(samplePicture), fileToUploadStream, cancellationToken).ConfigureAwait(false);
await log.WriteLineAsync($"File {uploadedFileId} uploaded").ConfigureAwait(false);

// SEND A MESSAGE WITH ATTACHMENT
messageId = await client.Chat.SendMessageToChannelAsync(channel.Id, "This message has an attachment", null, new[] { uploadedFileId }, null, cancellationToken).ConfigureAwait(false);
await log.WriteLineAsync($"Message \"{messageId}\" sent with attachment").ConfigureAwait(false);
await Task.Delay(1000, cancellationToken).ConfigureAwait(false); // Allow the Zoom system to process this message and avoid subsequent "message doesn't exist" error messages
}
}

// RETRIEVE LIST OF MESSAGES
var paginatedMessages = await client.Chat.GetMessagesToChannelAsync(channel.Id, 100, null, cancellationToken).ConfigureAwait(false);
await log.WriteLineAsync($"There are {paginatedMessages.TotalRecords} messages in channel \"{channel.Id}\"").ConfigureAwait(false);
Expand Down
1 change: 1 addition & 0 deletions Source/ZoomNet.IntegrationTests/Tests/Meetings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public async Task RunAsync(User myUser, string[] myPermissions, IZoomClient clie
// CLEANUP PREVIOUS INTEGRATION TESTS THAT MIGHT HAVE BEEN INTERRUPTED BEFORE THEY HAD TIME TO CLEANUP AFTER THEMSELVES
var cleanUpTasks = paginatedScheduledMeetings.Records
.Union(paginatedLiveMeetings.Records)
.Union(paginatedUpcomingMeetings.Records)
.Where(m => m.Topic.StartsWith("ZoomNet Integration Testing:"))
.Select(async oldMeeting =>
{
Expand Down
1 change: 0 additions & 1 deletion Source/ZoomNet.IntegrationTests/Tests/Webinars.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ public async Task RunAsync(User myUser, string[] myPermissions, IZoomClient clie

// CLEANUP PREVIOUS INTEGRATION TESTS THAT MIGHT HAVE BEEN INTERRUPTED BEFORE THEY HAD TIME TO CLEANUP AFTER THEMSELVES
var cleanUpTasks = paginatedWebinars.Records
.Union(paginatedWebinars.Records)
.Where(m => m.Topic.StartsWith("ZoomNet Integration Testing:"))
.Select(async oldWebinar =>
{
Expand Down
45 changes: 30 additions & 15 deletions Source/ZoomNet.IntegrationTests/TestsRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,23 +58,38 @@ public async Task<int> RunAsync()
{
var clientId = Environment.GetEnvironmentVariable("ZOOM_OAUTH_CLIENTID", EnvironmentVariableTarget.User);
var clientSecret = Environment.GetEnvironmentVariable("ZOOM_OAUTH_CLIENTSECRET", EnvironmentVariableTarget.User);

var accountId = Environment.GetEnvironmentVariable("ZOOM_OAUTH_ACCOUNTID", EnvironmentVariableTarget.User);
var refreshToken = Environment.GetEnvironmentVariable("ZOOM_OAUTH_REFRESHTOKEN", EnvironmentVariableTarget.User);
var accessToken = Environment.GetEnvironmentVariable("ZOOM_OAUTH_ACCESSTOKEN", EnvironmentVariableTarget.User);
connectionInfo = new OAuthConnectionInfo(clientId, clientSecret, refreshToken, accessToken,
(newRefreshToken, newAccessToken) =>
{
Environment.SetEnvironmentVariable("ZOOM_OAUTH_REFRESHTOKEN", newRefreshToken, EnvironmentVariableTarget.User);
Environment.SetEnvironmentVariable("ZOOM_OAUTH_ACCESSTOKEN", newAccessToken, EnvironmentVariableTarget.User);
});

//var authorizationCode = "<-- the code generated by Zoom when the app is authorized by the user -->";
//connectionInfo = new OAuthConnectionInfo(clientId, clientSecret, authorizationCode,
// (newRefreshToken, newAccessToken) =>
// {
// Environment.SetEnvironmentVariable("ZOOM_OAUTH_REFRESHTOKEN", newRefreshToken, EnvironmentVariableTarget.User);
// Environment.SetEnvironmentVariable("ZOOM_OAUTH_ACCESSTOKEN", newAccessToken, EnvironmentVariableTarget.User);
// });

// Server-to-Server OAuth
if (!string.IsNullOrEmpty(accountId))
{
connectionInfo = new OAuthConnectionInfo(clientId, clientSecret, accountId,
(_, newAccessToken) =>
{
Console.Out.WriteLine($"A new access token was issued: {newAccessToken}");
});
}

// Standard OAuth
else
{
connectionInfo = new OAuthConnectionInfo(clientId, clientSecret, refreshToken, accessToken,
(newRefreshToken, newAccessToken) =>
{
Environment.SetEnvironmentVariable("ZOOM_OAUTH_REFRESHTOKEN", newRefreshToken, EnvironmentVariableTarget.User);
Environment.SetEnvironmentVariable("ZOOM_OAUTH_ACCESSTOKEN", newAccessToken, EnvironmentVariableTarget.User);
});

//var authorizationCode = "<-- the code generated by Zoom when the app is authorized by the user -->";
//connectionInfo = new OAuthConnectionInfo(clientId, clientSecret, authorizationCode,
// (newRefreshToken, newAccessToken) =>
// {
// Environment.SetEnvironmentVariable("ZOOM_OAUTH_REFRESHTOKEN", newRefreshToken, EnvironmentVariableTarget.User);
// Environment.SetEnvironmentVariable("ZOOM_OAUTH_ACCESSTOKEN", newAccessToken, EnvironmentVariableTarget.User);
// });
}
}

var proxy = useFiddler ? new WebProxy($"http://localhost:{fiddlerPort}") : null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<ItemGroup>
<PackageReference Include="Logzio.DotNet.NLog" Version="1.0.13" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
<PackageReference Include="NLog.Extensions.Logging" Version="1.7.4" />
<PackageReference Include="NLog.Extensions.Logging" Version="1.7.5" />
</ItemGroup>

<ItemGroup>
Expand Down
7 changes: 6 additions & 1 deletion Source/ZoomNet.UnitTests/Utilities/OAuthTokenHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ public void Attempt_to_refresh_token_multiple_times_despite_exception()
var clientId = "abc123";
var clientSecret = "xyz789";
var authorizationCode = "INVALID_AUTH_CODE";
var connectionInfo = new OAuthConnectionInfo(clientId, clientSecret, authorizationCode, null);
var connectionInfo = new OAuthConnectionInfo(clientId, clientSecret, authorizationCode,
(newRefreshToken, newAccessToken) =>
{
// Intentionally left blank
},
null);
var apiResponse = "{ \"reason\":\"Invalid authorization code " + authorizationCode + "\",\"error\":\"invalid_request\"}";

var mockHttp = new MockHttpMessageHandler();
Expand Down
44 changes: 43 additions & 1 deletion Source/ZoomNet.UnitTests/WebhookParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ public class WebhookParserTests
""settings"": {
""use_pmi"": false,
""alternative_hosts"": """"
}
}
}
},
""event_ts"": 1617628462392
}";
Expand Down Expand Up @@ -128,6 +128,28 @@ public class WebhookParserTests
}
}";

private const string MEETING_SERVICE_ISSUE_WEBHOOK = @"
{
""event"": ""meeting.alert"",
""event_ts"": 1626230691572,
""payload"": {
""account_id"": ""AAAAAABBBB"",
""object"": {
""id"": 1234567890,
""uuid"": ""4444AAAiAAAAAiAiAiiAii=="",
""host_id"": ""x1yCzABCDEfg23HiJKl4mN"",
""topic"": ""My Meeting"",
""type"": 2,
""join_time"": ""2021-07-13T21:44:51Z"",
""timezone"": ""America/Los_Angeles"",
""duration"": 60,
""issues"": [
""Unstable audio quality""
]
}
}
}";

#endregion

[Fact]
Expand Down Expand Up @@ -239,5 +261,25 @@ public void MeetingSharingStarted()
parsedMeeting.Duration.ShouldBe(60);
parsedMeeting.Timezone.ShouldBe("America/Los_Angeles");
}

[Fact]
public void MeetingServiceIssue()
{
var parsedEvent = (MeetingServiceIssueEvent)new WebhookParser().ParseEventWebhook(MEETING_SERVICE_ISSUE_WEBHOOK);

parsedEvent.EventType.ShouldBe(EventType.MeetingServiceIssue);
parsedEvent.AccountId.ShouldBe("AAAAAABBBB");
parsedEvent.Timestamp.ShouldBe(new DateTime(2021, 7, 14, 2, 44, 51, 572, DateTimeKind.Utc));
parsedEvent.Issues.ShouldBe(new[] { "Unstable audio quality" });

parsedEvent.Meeting.GetType().ShouldBe(typeof(ScheduledMeeting));
var parsedMeeting = (ScheduledMeeting)parsedEvent.Meeting;
parsedMeeting.Id.ShouldBe(1234567890L);
parsedMeeting.Topic.ShouldBe("My Meeting");
parsedMeeting.Uuid.ShouldBe("4444AAAiAAAAAiAiAiiAii==");
parsedMeeting.HostId.ShouldBe("x1yCzABCDEfg23HiJKl4mN");
parsedMeeting.Duration.ShouldBe(60);
parsedMeeting.Timezone.ShouldBe("America/Los_Angeles");
}
}
}
6 changes: 3 additions & 3 deletions Source/ZoomNet.UnitTests/ZoomNet.UnitTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
<PackageReference Include="Moq" Version="4.17.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" />
<PackageReference Include="Moq" Version="4.18.1" />
<PackageReference Include="RichardSzalay.MockHttp" Version="6.0.0" />
<PackageReference Include="Shouldly" Version="4.0.3" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
Expand Down
Loading

0 comments on commit c547085

Please sign in to comment.