From 3b59b069868b1aecc208a7985083243e8bbdb68d Mon Sep 17 00:00:00 2001 From: Jericho Date: Sun, 10 May 2020 21:50:17 -0400 Subject: [PATCH 1/4] Rename Client class to ZoomClient --- .../IIntegrationTest.cs | 2 +- Source/ZoomNet.IntegrationTests/Program.cs | 2 +- .../Tests/Meetings.cs | 2 +- .../ZoomNet.IntegrationTests/TestsRunner.cs | 2 +- Source/ZoomNet/{IClient.cs => IZoomClient.cs} | 2 +- Source/ZoomNet/{Client.cs => ZoomClient.cs} | 33 ++++++++----------- 6 files changed, 19 insertions(+), 24 deletions(-) rename Source/ZoomNet/{IClient.cs => IZoomClient.cs} (94%) rename Source/ZoomNet/{Client.cs => ZoomClient.cs} (81%) diff --git a/Source/ZoomNet.IntegrationTests/IIntegrationTest.cs b/Source/ZoomNet.IntegrationTests/IIntegrationTest.cs index 3ba21d53..abc6b20a 100644 --- a/Source/ZoomNet.IntegrationTests/IIntegrationTest.cs +++ b/Source/ZoomNet.IntegrationTests/IIntegrationTest.cs @@ -6,6 +6,6 @@ namespace ZoomNet.IntegrationTests { public interface IIntegrationTest { - Task RunAsync(string userId, IClient client, TextWriter log, CancellationToken cancellationToken); + Task RunAsync(string userId, IZoomClient client, TextWriter log, CancellationToken cancellationToken); } } diff --git a/Source/ZoomNet.IntegrationTests/Program.cs b/Source/ZoomNet.IntegrationTests/Program.cs index c881343d..dc8b1aaa 100644 --- a/Source/ZoomNet.IntegrationTests/Program.cs +++ b/Source/ZoomNet.IntegrationTests/Program.cs @@ -37,7 +37,7 @@ private static LoggingConfiguration GetNLogConfiguration() { var logzioTarget = new LogzioTarget { Token = logzioToken }; logzioTarget.ContextProperties.Add(new TargetPropertyWithContext("source", "ZoomNet_integration_tests")); - logzioTarget.ContextProperties.Add(new TargetPropertyWithContext("ZoomNet-Version", ZoomNet.Client.Version)); + logzioTarget.ContextProperties.Add(new TargetPropertyWithContext("ZoomNet-Version", ZoomNet.ZoomClient.Version)); nLogConfig.AddTarget("Logzio", logzioTarget); nLogConfig.AddRule(NLog.LogLevel.Debug, NLog.LogLevel.Fatal, "Logzio", "*"); diff --git a/Source/ZoomNet.IntegrationTests/Tests/Meetings.cs b/Source/ZoomNet.IntegrationTests/Tests/Meetings.cs index 66d896a7..7bedf653 100644 --- a/Source/ZoomNet.IntegrationTests/Tests/Meetings.cs +++ b/Source/ZoomNet.IntegrationTests/Tests/Meetings.cs @@ -10,7 +10,7 @@ namespace ZoomNet.IntegrationTests.Tests { public class Meetings : IIntegrationTest { - public async Task RunAsync(string userId, IClient client, TextWriter log, CancellationToken cancellationToken) + public async Task RunAsync(string userId, IZoomClient client, TextWriter log, CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested) return; diff --git a/Source/ZoomNet.IntegrationTests/TestsRunner.cs b/Source/ZoomNet.IntegrationTests/TestsRunner.cs index 50003950..35b633fe 100644 --- a/Source/ZoomNet.IntegrationTests/TestsRunner.cs +++ b/Source/ZoomNet.IntegrationTests/TestsRunner.cs @@ -42,7 +42,7 @@ public async Task RunAsync() var apiSecret = Environment.GetEnvironmentVariable("ZOOM_APISECRET"); var userId = Environment.GetEnvironmentVariable("ZOOM_USERID"); var proxy = useFiddler ? new WebProxy("http://localhost:8888") : null; - var client = new Client(apiKey, apiSecret, proxy, null, _loggerFactory.CreateLogger()); + var client = new ZoomClient(apiKey, apiSecret, proxy, null, _loggerFactory.CreateLogger()); // Configure Console var source = new CancellationTokenSource(); diff --git a/Source/ZoomNet/IClient.cs b/Source/ZoomNet/IZoomClient.cs similarity index 94% rename from Source/ZoomNet/IClient.cs rename to Source/ZoomNet/IZoomClient.cs index b9eafa6d..37ee5d80 100644 --- a/Source/ZoomNet/IClient.cs +++ b/Source/ZoomNet/IZoomClient.cs @@ -5,7 +5,7 @@ namespace ZoomNet /// /// Interface for the Zoom REST client. /// - public interface IClient + public interface IZoomClient { /// /// Gets the resource which allows you to manage meetings. diff --git a/Source/ZoomNet/Client.cs b/Source/ZoomNet/ZoomClient.cs similarity index 81% rename from Source/ZoomNet/Client.cs rename to Source/ZoomNet/ZoomClient.cs index dfb427dd..c654cd10 100644 --- a/Source/ZoomNet/Client.cs +++ b/Source/ZoomNet/ZoomClient.cs @@ -12,9 +12,9 @@ namespace ZoomNet { /// - /// REST client for interacting with ZoomNet's API. + /// REST client for interacting with Zoom's API. /// - public class Client : IClient, IDisposable + public class ZoomClient : IZoomClient, IDisposable { #region FIELDS @@ -45,7 +45,7 @@ public static string Version { if (string.IsNullOrEmpty(_version)) { - _version = typeof(Client).GetTypeInfo().Assembly.GetName().Version.ToString(3); + _version = typeof(ZoomClient).GetTypeInfo().Assembly.GetName().Version.ToString(3); #if DEBUG _version = "DEBUG"; #endif @@ -55,11 +55,6 @@ public static string Version } } - /// - /// Gets the user agent. - /// - public static string UserAgent { get; private set; } - /// /// Gets the resource which allows you to manage meetings. /// @@ -81,57 +76,57 @@ public static string Version #region CTOR /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// Your Zoom API Key. /// Your Zoom API Secret. /// Options for the Zoom client. /// Logger. - public Client(string apiKey, string apiSecret, ZoomClientOptions options = null, ILogger logger = null) + public ZoomClient(string apiKey, string apiSecret, ZoomClientOptions options = null, ILogger logger = null) : this(apiKey, apiSecret, null, false, options, logger) { } /// - /// Initializes a new instance of the class with a specific proxy. + /// Initializes a new instance of the class with a specific proxy. /// /// Your Zoom API Key. /// Your Zoom API Secret. /// Allows you to specify a proxy. /// Options for the Zoom client. /// Logger. - public Client(string apiKey, string apiSecret, IWebProxy proxy, ZoomClientOptions options = null, ILogger logger = null) + public ZoomClient(string apiKey, string apiSecret, IWebProxy proxy, ZoomClientOptions options = null, ILogger logger = null) : this(apiKey, apiSecret, new HttpClient(new HttpClientHandler { Proxy = proxy, UseProxy = proxy != null }), true, options, logger) { } /// - /// Initializes a new instance of the class with a specific handler. + /// Initializes a new instance of the class with a specific handler. /// /// Your Zoom API Key. /// Your Zoom API Secret. /// TThe HTTP handler stack to use for sending requests. /// Options for the Zoom client. /// Logger. - public Client(string apiKey, string apiSecret, HttpMessageHandler handler, ZoomClientOptions options = null, ILogger logger = null) + public ZoomClient(string apiKey, string apiSecret, HttpMessageHandler handler, ZoomClientOptions options = null, ILogger logger = null) : this(apiKey, apiSecret, new HttpClient(handler), true, options, logger) { } /// - /// Initializes a new instance of the class with a specific http client. + /// Initializes a new instance of the class with a specific http client. /// /// Your Zoom API Key. /// Your Zoom API Secret. /// Allows you to inject your own HttpClient. This is useful, for example, to setup the HtppClient with a proxy. /// Options for the Zoom client. /// Logger. - public Client(string apiKey, string apiSecret, HttpClient httpClient, ZoomClientOptions options = null, ILogger logger = null) + public ZoomClient(string apiKey, string apiSecret, HttpClient httpClient, ZoomClientOptions options = null, ILogger logger = null) : this(apiKey, apiSecret, httpClient, false, options, logger) { } - private Client(string apiKey, string apiSecret, HttpClient httpClient, bool disposeClient, ZoomClientOptions options, ILogger logger = null) + private ZoomClient(string apiKey, string apiSecret, HttpClient httpClient, bool disposeClient, ZoomClientOptions options, ILogger logger = null) { _mustDisposeHttpClient = disposeClient; _httpClient = httpClient; @@ -154,9 +149,9 @@ private Client(string apiKey, string apiSecret, HttpClient httpClient, bool disp } /// - /// Finalizes an instance of the class. + /// Finalizes an instance of the class. /// - ~Client() + ~ZoomClient() { // The object went out of scope and finalized is called. // Call 'Dispose' to release unmanaged resources From ccb55674def0d2076301c1070373f0120207438d Mon Sep 17 00:00:00 2001 From: Jericho Date: Mon, 11 May 2020 00:36:26 -0400 Subject: [PATCH 2/4] Trow a meaningful exception if apiKey or apiSecret is null --- Source/ZoomNet/Utilities/JwtTokenHandler.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Source/ZoomNet/Utilities/JwtTokenHandler.cs b/Source/ZoomNet/Utilities/JwtTokenHandler.cs index fc78ae61..787234a1 100644 --- a/Source/ZoomNet/Utilities/JwtTokenHandler.cs +++ b/Source/ZoomNet/Utilities/JwtTokenHandler.cs @@ -1,4 +1,4 @@ -using Jose; +using Jose; using Pathoschild.Http.Client; using Pathoschild.Http.Client.Extensibility; using System; @@ -25,8 +25,8 @@ internal class JwtTokenHandler : IHttpFilter public JwtTokenHandler(string apiKey, string apiSecret, TimeSpan? tokenLifeSpan = null, TimeSpan? clockSkew = null) { - _apiKey = apiKey; - _apiSecret = apiSecret; + _apiKey = apiKey ?? throw new ArgumentNullException(nameof(apiKey)); + _apiSecret = apiSecret ?? throw new ArgumentNullException(nameof(apiSecret)); _tokenLifeSpan = tokenLifeSpan.GetValueOrDefault(TimeSpan.FromMinutes(30)); _clockSkew = clockSkew.GetValueOrDefault(TimeSpan.FromMinutes(5)); } From f37f273949a5a9392735fd3dd7f6c62c6044a993 Mon Sep 17 00:00:00 2001 From: Jericho Date: Mon, 11 May 2020 00:38:20 -0400 Subject: [PATCH 3/4] Fix bug in JwtTokenHandler that caused the token to be generated with every single request instead of once every "life span" (which is 30 minutes by default) --- Source/ZoomNet/Utilities/JwtTokenHandler.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Source/ZoomNet/Utilities/JwtTokenHandler.cs b/Source/ZoomNet/Utilities/JwtTokenHandler.cs index 787234a1..1b468b89 100644 --- a/Source/ZoomNet/Utilities/JwtTokenHandler.cs +++ b/Source/ZoomNet/Utilities/JwtTokenHandler.cs @@ -17,11 +17,11 @@ internal class JwtTokenHandler : IHttpFilter private readonly string _apiKey; private readonly string _apiSecret; - private readonly TimeSpan _clockSkew = TimeSpan.FromMinutes(5); - private readonly TimeSpan _tokenLifeSpan = TimeSpan.FromMinutes(30); - private readonly DateTime _jwtTokenExpiration = DateTime.MinValue; + private readonly TimeSpan _clockSkew; + private readonly TimeSpan _tokenLifeSpan; private string _jwtToken; + private DateTime _tokenExpiration; public JwtTokenHandler(string apiKey, string apiSecret, TimeSpan? tokenLifeSpan = null, TimeSpan? clockSkew = null) { @@ -29,6 +29,7 @@ public JwtTokenHandler(string apiKey, string apiSecret, TimeSpan? tokenLifeSpan _apiSecret = apiSecret ?? throw new ArgumentNullException(nameof(apiSecret)); _tokenLifeSpan = tokenLifeSpan.GetValueOrDefault(TimeSpan.FromMinutes(30)); _clockSkew = clockSkew.GetValueOrDefault(TimeSpan.FromMinutes(5)); + _tokenExpiration = DateTime.MinValue; } /// Method invoked just before the HTTP request is submitted. This method can modify the outgoing HTTP request. @@ -52,10 +53,11 @@ private void RefreshTokenIfNecessary() { if (TokenIsExpired()) { + _tokenExpiration = DateTime.UtcNow.Add(_tokenLifeSpan); var jwtPayload = new Dictionary() { { "iss", _apiKey }, - { "exp", DateTime.UtcNow.Add(_tokenLifeSpan).ToUnixTime() } + { "exp", _tokenExpiration.ToUnixTime() } }; _jwtToken = JWT.Encode(jwtPayload, Encoding.ASCII.GetBytes(_apiSecret), JwsAlgorithm.HS256); } @@ -65,7 +67,7 @@ private void RefreshTokenIfNecessary() private bool TokenIsExpired() { - return _jwtTokenExpiration <= DateTime.UtcNow.Add(_clockSkew); + return _tokenExpiration <= DateTime.UtcNow.Add(_clockSkew); } } } From 9f60467cb1190889483a5cdee8a90319f82d4dad Mon Sep 17 00:00:00 2001 From: Jericho Date: Mon, 11 May 2020 14:23:37 -0400 Subject: [PATCH 4/4] Support OAuth connection type --- .../ZoomNet.IntegrationTests/TestsRunner.cs | 30 +++++- Source/ZoomNet.sln | 6 +- Source/ZoomNet/IConnectionInfo.cs | 9 ++ Source/ZoomNet/JwtConnectionInfo.cs | 29 ++++++ Source/ZoomNet/Models/OAuthGrantType.cs | 22 +++++ Source/ZoomNet/OAuthConnectionInfo.cs | 70 ++++++++++++++ Source/ZoomNet/Utilities/Extensions.cs | 5 +- Source/ZoomNet/Utilities/JwtTokenHandler.cs | 15 +-- Source/ZoomNet/Utilities/OAuthTokenHandler.cs | 95 +++++++++++++++++++ Source/ZoomNet/ZoomClient.cs | 48 ++++++---- 10 files changed, 292 insertions(+), 37 deletions(-) create mode 100644 Source/ZoomNet/IConnectionInfo.cs create mode 100644 Source/ZoomNet/JwtConnectionInfo.cs create mode 100644 Source/ZoomNet/Models/OAuthGrantType.cs create mode 100644 Source/ZoomNet/OAuthConnectionInfo.cs create mode 100644 Source/ZoomNet/Utilities/OAuthTokenHandler.cs diff --git a/Source/ZoomNet.IntegrationTests/TestsRunner.cs b/Source/ZoomNet.IntegrationTests/TestsRunner.cs index 35b633fe..6c12f1e5 100644 --- a/Source/ZoomNet.IntegrationTests/TestsRunner.cs +++ b/Source/ZoomNet.IntegrationTests/TestsRunner.cs @@ -23,6 +23,12 @@ private enum ResultCodes Cancelled = 1223 } + private enum ConnectionMethods + { + Jwt = 0, + OAuth = 1 + } + private readonly ILoggerFactory _loggerFactory; public TestsRunner(ILoggerFactory loggerFactory) @@ -35,14 +41,30 @@ public async Task RunAsync() // ----------------------------------------------------------------------------- // Do you want to proxy requests through Fiddler? Can be useful for debugging. var useFiddler = true; - // ----------------------------------------------------------------------------- + + // Do you want to use JWT or OAuth? + var connectionMethod = ConnectionMethods.OAuth; + // -----------------------------------;------------------------------------------ // Configure ZoomNet client - var apiKey = Environment.GetEnvironmentVariable("ZOOM_APIKEY"); - var apiSecret = Environment.GetEnvironmentVariable("ZOOM_APISECRET"); + IConnectionInfo connectionInfo; + if (connectionMethod == ConnectionMethods.Jwt) + { + var apiKey = Environment.GetEnvironmentVariable("ZOOM_JWT_APIKEY"); + var apiSecret = Environment.GetEnvironmentVariable("ZOOM_JWT_APISECRET"); + connectionInfo = new JwtConnectionInfo(apiKey, apiSecret); + } + else + { + var clientId = Environment.GetEnvironmentVariable("ZOOM_OAUTH_CLIENTID"); + var clientSecret = Environment.GetEnvironmentVariable("ZOOM_OAUTH_CLIENTSECRET"); + var authorizationCode = Environment.GetEnvironmentVariable("ZOOM_OAUTH_AUTHORIZATIONCODE"); + connectionInfo = new OAuthConnectionInfo(clientId, clientSecret, authorizationCode); + } + var userId = Environment.GetEnvironmentVariable("ZOOM_USERID"); var proxy = useFiddler ? new WebProxy("http://localhost:8888") : null; - var client = new ZoomClient(apiKey, apiSecret, proxy, null, _loggerFactory.CreateLogger()); + var client = new ZoomClient(connectionInfo, proxy, null, _loggerFactory.CreateLogger()); // Configure Console var source = new CancellationTokenSource(); diff --git a/Source/ZoomNet.sln b/Source/ZoomNet.sln index e75cf978..31c8a39c 100644 --- a/Source/ZoomNet.sln +++ b/Source/ZoomNet.sln @@ -1,11 +1,11 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.28307.421 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30011.22 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ZoomNet", "ZoomNet\ZoomNet.csproj", "{1F1336D3-20EE-4EFD-868B-A5FD6E9F260D}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ZoomNet.IntegrationTests", "ZoomNet.IntegrationTests\ZoomNet.IntegrationTests.csproj", "{86BE46FC-FD82-45B3-8092-0C9AC1A94E8A}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ZoomNet.IntegrationTests", "ZoomNet.IntegrationTests\ZoomNet.IntegrationTests.csproj", "{86BE46FC-FD82-45B3-8092-0C9AC1A94E8A}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Source", "Source", "{8D45A893-7A48-4D45-9FD7-424B12D4C672}" EndProject diff --git a/Source/ZoomNet/IConnectionInfo.cs b/Source/ZoomNet/IConnectionInfo.cs new file mode 100644 index 00000000..2e21a031 --- /dev/null +++ b/Source/ZoomNet/IConnectionInfo.cs @@ -0,0 +1,9 @@ +namespace ZoomNet +{ + /// + /// Interface for connection information. + /// + public interface IConnectionInfo + { + } +} diff --git a/Source/ZoomNet/JwtConnectionInfo.cs b/Source/ZoomNet/JwtConnectionInfo.cs new file mode 100644 index 00000000..eb498085 --- /dev/null +++ b/Source/ZoomNet/JwtConnectionInfo.cs @@ -0,0 +1,29 @@ +namespace ZoomNet +{ + /// + /// Connect using JWT. + /// + public class JwtConnectionInfo : IConnectionInfo + { + /// + /// Gets the API Key. + /// + public string ApiKey { get; } + + /// + /// Gets the API Secret. + /// + public string ApiSecret { get; } + + /// + /// Initializes a new instance of the class. + /// + /// Your JWT app API Key. + /// Your JWT app API Secret. + public JwtConnectionInfo(string apiKey, string apiSecret) + { + ApiKey = apiKey; + ApiSecret = apiSecret; + } + } +} diff --git a/Source/ZoomNet/Models/OAuthGrantType.cs b/Source/ZoomNet/Models/OAuthGrantType.cs new file mode 100644 index 00000000..a627e742 --- /dev/null +++ b/Source/ZoomNet/Models/OAuthGrantType.cs @@ -0,0 +1,22 @@ +using System.Runtime.Serialization; + +namespace ZoomNet.Models +{ + /// + /// Enumeration to indicate the OAuth grant type. + /// + public enum OAuthGrantType + { + /// + /// Authorization code. This is the most commonly used grant type for Zoom APIs. + /// + [EnumMember(Value = "authorization_code")] + AuthorizationCode, + + /// + /// Client Credentials. + /// + [EnumMember(Value = "client_credentials")] + ClientCredentials + } +} diff --git a/Source/ZoomNet/OAuthConnectionInfo.cs b/Source/ZoomNet/OAuthConnectionInfo.cs new file mode 100644 index 00000000..b8aeea9d --- /dev/null +++ b/Source/ZoomNet/OAuthConnectionInfo.cs @@ -0,0 +1,70 @@ +using System; +using ZoomNet.Models; + +namespace ZoomNet +{ + /// + /// Connect using OAuth. + /// + public class OAuthConnectionInfo : IConnectionInfo + { + /// + /// Gets the client id. + /// + public string ClientId { get; } + + /// + /// Gets the client secret. + /// + public string ClientSecret { get; } + + /// + /// Gets the grant type. + /// + public OAuthGrantType GrantType { get; } + + /// + /// Gets the authorization code. + /// + /// This value is relevant only if the grant type is "AuthorizationCode". + public string AuthorizationCode { get; } + + /// + /// Initializes a new instance of the class. + /// + /// + /// This constructor is used to get access token for APIs that do not + /// need a user’s permission, but rather a service’s permission. + /// Within the realm of Zoom APIs, Client Credentials grant should be + /// used to get access token from the Chatbot Service in order to use + /// the "Send Chatbot Messages API". See the "Using OAuth 2.0 / Client + /// Credentials" section in the "Using Zoom APIs" document for more details + /// (https://marketplace.zoom.us/docs/api-reference/using-zoom-apis). + /// + /// Your Client Id. + /// Your Client Secret. + public OAuthConnectionInfo(string clientId, string clientSecret) + { + ClientId = clientId ?? throw new ArgumentNullException(nameof(clientId)); + ClientSecret = clientSecret ?? throw new ArgumentNullException(nameof(clientSecret)); + GrantType = OAuthGrantType.ClientCredentials; + } + + /// + /// Initializes a new instance of the class. + /// + /// + /// This is the most commonly used grant type for Zoom APIs. + /// + /// Your Client Id. + /// Your Client Secret. + /// The authorization code. + public OAuthConnectionInfo(string clientId, string clientSecret, string authorizationCode) + { + ClientId = clientId ?? throw new ArgumentNullException(nameof(clientId)); + ClientSecret = clientSecret ?? throw new ArgumentNullException(nameof(clientSecret)); + AuthorizationCode = authorizationCode ?? throw new ArgumentNullException(nameof(authorizationCode)); + GrantType = OAuthGrantType.AuthorizationCode; + } + } +} diff --git a/Source/ZoomNet/Utilities/Extensions.cs b/Source/ZoomNet/Utilities/Extensions.cs index 2b8c4d0b..fd6af5af 100644 --- a/Source/ZoomNet/Utilities/Extensions.cs +++ b/Source/ZoomNet/Utilities/Extensions.cs @@ -360,9 +360,9 @@ public static void AddPropertyIfValue(this JObject jsonObject, string propert jsonObject.Add(propertyName, JArray.FromObject(value.ToArray(), jsonSerializer)); } - public static T GetPropertyValue(this JToken item, string name) + public static T GetPropertyValue(this JToken item, string name, T defaultValue = default) { - if (item[name] == null) return default; + if (item[name] == null) return defaultValue; return item[name].Value(); } @@ -507,7 +507,6 @@ public static void CheckForZoomErrors(this IResponse response) { try { - // Check for the presence of property called 'errors' var jObject = JObject.Parse(responseContent); var codeProperty = jObject["code"]; var messageProperty = jObject["message"]; diff --git a/Source/ZoomNet/Utilities/JwtTokenHandler.cs b/Source/ZoomNet/Utilities/JwtTokenHandler.cs index 1b468b89..ea812651 100644 --- a/Source/ZoomNet/Utilities/JwtTokenHandler.cs +++ b/Source/ZoomNet/Utilities/JwtTokenHandler.cs @@ -15,18 +15,19 @@ internal class JwtTokenHandler : IHttpFilter { private static readonly object _lock = new object(); - private readonly string _apiKey; - private readonly string _apiSecret; + private readonly JwtConnectionInfo _connectionInfo; private readonly TimeSpan _clockSkew; private readonly TimeSpan _tokenLifeSpan; private string _jwtToken; private DateTime _tokenExpiration; - public JwtTokenHandler(string apiKey, string apiSecret, TimeSpan? tokenLifeSpan = null, TimeSpan? clockSkew = null) + public JwtTokenHandler(JwtConnectionInfo connectionInfo, TimeSpan? tokenLifeSpan = null, TimeSpan? clockSkew = null) { - _apiKey = apiKey ?? throw new ArgumentNullException(nameof(apiKey)); - _apiSecret = apiSecret ?? throw new ArgumentNullException(nameof(apiSecret)); + if (string.IsNullOrEmpty(connectionInfo.ApiKey)) throw new ArgumentNullException(nameof(connectionInfo.ApiKey)); + if (string.IsNullOrEmpty(connectionInfo.ApiSecret)) throw new ArgumentNullException(nameof(connectionInfo.ApiSecret)); + + _connectionInfo = connectionInfo; _tokenLifeSpan = tokenLifeSpan.GetValueOrDefault(TimeSpan.FromMinutes(30)); _clockSkew = clockSkew.GetValueOrDefault(TimeSpan.FromMinutes(5)); _tokenExpiration = DateTime.MinValue; @@ -56,10 +57,10 @@ private void RefreshTokenIfNecessary() _tokenExpiration = DateTime.UtcNow.Add(_tokenLifeSpan); var jwtPayload = new Dictionary() { - { "iss", _apiKey }, + { "iss", _connectionInfo.ApiKey }, { "exp", _tokenExpiration.ToUnixTime() } }; - _jwtToken = JWT.Encode(jwtPayload, Encoding.ASCII.GetBytes(_apiSecret), JwsAlgorithm.HS256); + _jwtToken = JWT.Encode(jwtPayload, Encoding.ASCII.GetBytes(_connectionInfo.ApiSecret), JwsAlgorithm.HS256); } } } diff --git a/Source/ZoomNet/Utilities/OAuthTokenHandler.cs b/Source/ZoomNet/Utilities/OAuthTokenHandler.cs new file mode 100644 index 00000000..5ad84a83 --- /dev/null +++ b/Source/ZoomNet/Utilities/OAuthTokenHandler.cs @@ -0,0 +1,95 @@ +using Newtonsoft.Json.Linq; +using Pathoschild.Http.Client; +using Pathoschild.Http.Client.Extensibility; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Runtime.Serialization; +using System.Text; +using ZoomNet.Models; + +namespace ZoomNet.Utilities +{ + /// + /// Handler to ensure requests to the Zoom API include a valid JWT token. + /// + /// + internal class OAuthTokenHandler : IHttpFilter + { + private static readonly object _lock = new object(); + + private readonly OAuthConnectionInfo _connectionInfo; + private readonly HttpClient _httpClient; + private readonly TimeSpan _clockSkew; + + private string _accessToken; + private IDictionary _tokenScope; + private DateTime _tokenExpiration; + + public OAuthTokenHandler(OAuthConnectionInfo connectionInfo, HttpClient httpClient, TimeSpan? clockSkew = null) + { + if (string.IsNullOrEmpty(connectionInfo.ClientId)) throw new ArgumentNullException(nameof(connectionInfo.ClientId)); + if (string.IsNullOrEmpty(connectionInfo.ClientSecret)) throw new ArgumentNullException(nameof(connectionInfo.ClientSecret)); + + _connectionInfo = connectionInfo; + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _clockSkew = clockSkew.GetValueOrDefault(TimeSpan.FromMinutes(5)); + _tokenExpiration = DateTime.MinValue; + } + + /// Method invoked just before the HTTP request is submitted. This method can modify the outgoing HTTP request. + /// The HTTP request. + public void OnRequest(IRequest request) + { + RefreshTokenIfNecessary(); + request.WithBearerAuthentication(_accessToken); + } + + /// Method invoked just after the HTTP response is received. This method can modify the incoming HTTP response. + /// The HTTP response. + /// Whether HTTP error responses should be raised as exceptions. + public void OnResponse(IResponse response, bool httpErrorAsException) { } + + private void RefreshTokenIfNecessary() + { + if (TokenIsExpired()) + { + lock (_lock) + { + if (TokenIsExpired()) + { + var grantType = _connectionInfo.GrantType.GetAttributeOfType().Value; + var requestUrl = $"https://api.zoom.us/oauth/token?grant_type={grantType}"; + if (_connectionInfo.GrantType == OAuthGrantType.AuthorizationCode) requestUrl += $"&code={_connectionInfo.AuthorizationCode}"; + + var request = new HttpRequestMessage(HttpMethod.Post, requestUrl); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{_connectionInfo.ClientId}:{_connectionInfo.ClientSecret}"))); + var response = _httpClient.SendAsync(request).GetAwaiter().GetResult(); + var responseContent = response.Content.ReadAsStringAsync(null).ConfigureAwait(false).GetAwaiter().GetResult(); + var jObject = JObject.Parse(responseContent); + + if (!response.IsSuccessStatusCode) + { + throw new ZoomException(jObject.GetPropertyValue("reason"), response, "No diagnostic available"); + } + + _accessToken = jObject.GetPropertyValue("access_token"); + var scope = jObject.GetPropertyValue("scope"); + _tokenScope = scope + .Split(' ') + .Select(x => x.Split(':')) + .ToDictionary(x => x[0], x => x.Skip(1).ToArray()); + _tokenExpiration = DateTime.UtcNow.AddSeconds(jObject.GetPropertyValue("expires_in", 60 * 60)); + } + } + } + } + + private bool TokenIsExpired() + { + return _tokenExpiration <= DateTime.UtcNow.Add(_clockSkew); + } + } +} diff --git a/Source/ZoomNet/ZoomClient.cs b/Source/ZoomNet/ZoomClient.cs index c654cd10..44cd7aa2 100644 --- a/Source/ZoomNet/ZoomClient.cs +++ b/Source/ZoomNet/ZoomClient.cs @@ -78,55 +78,51 @@ public static string Version /// /// Initializes a new instance of the class. /// - /// Your Zoom API Key. - /// Your Zoom API Secret. + /// Connection information. /// Options for the Zoom client. /// Logger. - public ZoomClient(string apiKey, string apiSecret, ZoomClientOptions options = null, ILogger logger = null) - : this(apiKey, apiSecret, null, false, options, logger) + public ZoomClient(IConnectionInfo connectionInfo, ZoomClientOptions options = null, ILogger logger = null) + : this(connectionInfo, null, false, options, logger) { } /// /// Initializes a new instance of the class with a specific proxy. /// - /// Your Zoom API Key. - /// Your Zoom API Secret. + /// Connection information. /// Allows you to specify a proxy. /// Options for the Zoom client. /// Logger. - public ZoomClient(string apiKey, string apiSecret, IWebProxy proxy, ZoomClientOptions options = null, ILogger logger = null) - : this(apiKey, apiSecret, new HttpClient(new HttpClientHandler { Proxy = proxy, UseProxy = proxy != null }), true, options, logger) + public ZoomClient(IConnectionInfo connectionInfo, IWebProxy proxy, ZoomClientOptions options = null, ILogger logger = null) + : this(connectionInfo, new HttpClient(new HttpClientHandler { Proxy = proxy, UseProxy = proxy != null }), true, options, logger) { } /// /// Initializes a new instance of the class with a specific handler. /// - /// Your Zoom API Key. - /// Your Zoom API Secret. + /// Connection information. /// TThe HTTP handler stack to use for sending requests. /// Options for the Zoom client. /// Logger. - public ZoomClient(string apiKey, string apiSecret, HttpMessageHandler handler, ZoomClientOptions options = null, ILogger logger = null) - : this(apiKey, apiSecret, new HttpClient(handler), true, options, logger) + public ZoomClient(IConnectionInfo connectionInfo, HttpMessageHandler handler, ZoomClientOptions options = null, ILogger logger = null) + : this(connectionInfo, new HttpClient(handler), true, options, logger) { } /// /// Initializes a new instance of the class with a specific http client. /// - /// Your Zoom API Key. - /// Your Zoom API Secret. + /// Connection information. /// Allows you to inject your own HttpClient. This is useful, for example, to setup the HtppClient with a proxy. /// Options for the Zoom client. /// Logger. - public ZoomClient(string apiKey, string apiSecret, HttpClient httpClient, ZoomClientOptions options = null, ILogger logger = null) - : this(apiKey, apiSecret, httpClient, false, options, logger) + public ZoomClient(IConnectionInfo connectionInfo, HttpClient httpClient, ZoomClientOptions options = null, ILogger logger = null) + : this(connectionInfo, httpClient, false, options, logger) { } - private ZoomClient(string apiKey, string apiSecret, HttpClient httpClient, bool disposeClient, ZoomClientOptions options, ILogger logger = null) + private ZoomClient(IConnectionInfo connectionInfo, HttpClient httpClient, bool disposeClient, ZoomClientOptions options, ILogger logger = null) { _mustDisposeHttpClient = disposeClient; _httpClient = httpClient; @@ -138,9 +134,21 @@ private ZoomClient(string apiKey, string apiSecret, HttpClient httpClient, bool _fluentClient.Filters.Remove(); - // Order is important: JwtTokenHandler, must be first, followed by DiagnosticHandler and then by ErrorHandler. - // Also, the list of filters must be kept in sync with the filters in Utils.GetFluentClient in the unit testing project. - _fluentClient.Filters.Add(new JwtTokenHandler(apiKey, apiSecret)); + // Order is important: the token handler (either JWT or OAuth) must be first, followed by DiagnosticHandler and then by ErrorHandler. + if (connectionInfo is JwtConnectionInfo jwtConnectionInfo) + { + _fluentClient.Filters.Add(new JwtTokenHandler(jwtConnectionInfo)); + } + else if (connectionInfo is OAuthConnectionInfo oauthConnectionInfo) + { + _fluentClient.Filters.Add(new OAuthTokenHandler(oauthConnectionInfo, httpClient)); + } + else + { + throw new ZoomException($"{connectionInfo.GetType()} is an unknown connection type", null, null, null); + } + + // The list of filters must be kept in sync with the filters in Utils.GetFluentClient in the unit testing project. _fluentClient.Filters.Add(new DiagnosticHandler(_options.LogLevelSuccessfulCalls, _options.LogLevelFailedCalls)); _fluentClient.Filters.Add(new ZoomErrorHandler());