Skip to content

Commit

Permalink
Feature: new Authentication process (#23)
Browse files Browse the repository at this point in the history
* feat: add additional pckges to proj

* feat: REST implement of JWT auth

* test:fix: ApiTests

* feat: implement of JWT for WebSocket connection

* refactor: new Auth in ApiClient

* test:refactor: CoinbaseBrokerage tests

* rename: apiKey and apiSecret to name and privateKey

* rename: config name of keys

* remove: extra nuget to validate JWT token
test:feat: validate JWT token

* fix: missed OrderStatus in Order WS response

* refactor: use aggregator instance like part of Instance

* remove: extra GenerateRestToken in ApiClient

* refactor: reuse constant variable to save performance

* refactor: RandomHex()

* remove: parameter in RandomHex

* refactor: ParseKey() in ApiClient

* feat: condition to prevent creating string WS response
  • Loading branch information
Romazes authored Aug 21, 2024
1 parent ab08b92 commit 7c2b562
Show file tree
Hide file tree
Showing 20 changed files with 417 additions and 219 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/gh-actions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ jobs:
build:
runs-on: ubuntu-20.04
env:
QC_COINBASE_API_SECRET: ${{ secrets.QC_COINBASE_API_SECRET }}
QC_COINBASE_API_KEY: ${{ secrets.QC_COINBASE_API_KEY }}
QC_COINBASE_API_NAME: ${{ secrets.QC_COINBASE_API_NAME }}
QC_COINBASE_API_PRIVATE_KEY: ${{ secrets.QC_COINBASE_API_PRIVATE_KEY }}
QC_COINBASE_URL: ${{ secrets.QC_COINBASE_URL }}
QC_COINBASE_REST_API: ${{ secrets.QC_COINBASE_REST_API }}
QC_JOB_USER_ID: ${{ secrets.JOB_USER_ID }}
Expand Down
137 changes: 116 additions & 21 deletions QuantConnect.CoinbaseBrokerage.Tests/CoinbaseApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
using Newtonsoft.Json.Linq;
using QuantConnect.Configuration;
using System.Collections.Generic;
using System.Security.Cryptography;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using QuantConnect.Brokerages.Coinbase.Api;
using QuantConnect.Brokerages.Coinbase.Models.Enums;
using QuantConnect.Brokerages.Coinbase.Models.WebSocket;
Expand All @@ -35,30 +38,38 @@ public class CoinbaseApiTests
[SetUp]
public void Setup()
{
var apiKey = Config.Get("coinbase-api-key");
var apiKeySecret = Config.Get("coinbase-api-secret");
var name = Config.Get("coinbase-api-name");
var priavteKey = Config.Get("coinbase-api-private-key");

CoinbaseApi = CreateCoinbaseApi(apiKey, apiKeySecret);
CoinbaseApi = CreateCoinbaseApi(name, priavteKey);
}

[TestCase("", "")]
[TestCase("1", "2")]
public void InvalidAuthenticationCredentialsShouldThrowException(string apiKey, string apiKeySecret)
[TestCase("", "", typeof(ArgumentOutOfRangeException))]
[TestCase("organizations/2c7dhs-a3a3-4acf-aa0c-f68584f34c37/apiKeys/41090ffa-asd2-4040-815f-afaf63747e35", "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIPcJGfXYEdLQi0iFj1xvGfPwuRNoeddwuKS4xL2NrlGWpoAoGCCqGSM49\nAwEHoUQDQgAEclN+asd/EhJ3UjOWkHmP/iqGBv5NkNJ75bUq\nVgxS4aU3/djHiIuSf27QasdOFIDGJLmOn7YiQ==\n-----END EC PRIVATE KEY-----\n", typeof(CryptographicException))]
[TestCase("organizations/2c7dhs-a3a3-4acf-aa0c-f68584f34c37/apiKeys/41090ffa-asd2-4040-815f-afaf63747e35", "MHcCAQEEIPcJGfXYEdLQi0iFj1xvGfPwuRNoeddwuKS4xL2NrlGWpoAoGCCqGSM49\nAwEHoUQDQgAEclN+asd/EhJ3UjOWkHmP/iqGBv5NkNJ75bUq\nVgxS4aU3/djHiIuSf27QasdOFIDGJLmOn7YiQ==", typeof(CryptographicException))]
public void InvalidAuthenticationCredentialsShouldThrowException(string name, string privateKey, Type expectedException)
{
var coinbaseApi = CreateCoinbaseApi(apiKey, apiKeySecret);
try
{
var coinbaseApi = CreateCoinbaseApi(name, privateKey);

// call random endpoint with incorrect credential
Assert.Throws<Exception>(() => coinbaseApi.GetAccounts());
// call random endpoint with incorrect credential
Assert.Throws(expectedException, () => coinbaseApi.GetAccounts());
}
catch (Exception ex)
{
Assert.IsInstanceOf(expectedException, ex);
}
}

[Test]
public void GetListAccounts()
[Test]
public void GetListAccounts()
{
var accounts = CoinbaseApi.GetAccounts();

Assert.Greater(accounts.Count(), 0);

foreach(var account in accounts)
foreach (var account in accounts)
{
Assert.IsTrue(account.Active);
Assert.IsNotEmpty(account.Name);
Expand Down Expand Up @@ -161,22 +172,22 @@ public void ParseWebSocketLevel2DataResponse()
Assert.GreaterOrEqual(tick.NewQuantity, 0);
Assert.GreaterOrEqual(tick.PriceLevel, 0);
Assert.IsInstanceOf<CoinbaseLevel2UpdateSide>(tick.Side);
}
}
}

[TestCase("/api/v3/brokerage/orders", null, "Unauthorized")]
[TestCase("/api/v3/brokerage/orders", "", "Unauthorized")]
[TestCase("/api/v3/brokerage/orders", null, "Bad Request")]
[TestCase("/api/v3/brokerage/orders", "", "Bad Request")]
[TestCase("/api/v3/brokerage/orders", "{null}", "Bad Request")]
[TestCase("/api/v3/brokerage/orders", "[]", "Unauthorized")]
[TestCase("/api/v3/brokerage/orders", "[]", "Bad Request")]
public void ValidateCoinbaseRestRequestWithWrongBodyParameter(string uriPath, object bodyData, string message)
{
var apiKey = Config.Get("coinbase-api-key");
var apiKeySecret = Config.Get("coinbase-api-secret");
var name = Config.Get("coinbase-api-name");
var privateKey = Config.Get("coinbase-api-private-key");
var restApiUrl = Config.Get("coinbase-rest-api", "https://api.coinbase.com");

var request = new RestRequest($"{uriPath}", Method.POST);

var _apiClient = new CoinbaseApiClient(apiKey, apiKeySecret, restApiUrl, 30);
var _apiClient = new CoinbaseApiClient(name, privateKey, restApiUrl, 30);

request.AddJsonBody(bodyData);

Expand All @@ -199,11 +210,95 @@ public void CancelOrderWithWrongOrderId(string orderId, string errorMessage)
Assert.AreEqual(errorMessage, response.FailureReason);
}

private CoinbaseApi CreateCoinbaseApi(string apiKey, string apiKeySecret)
[Test]
public void ValidateGenerateWebSocketJWTToken()
{
var name = Config.Get("coinbase-api-name");
var privateKey = Config.Get("coinbase-api-private-key");
var restApiUrl = Config.Get("coinbase-rest-api", "https://api.coinbase.com");

var _apiClient = new CoinbaseApiClient(name, privateKey, restApiUrl, 30);

var generateWebSocketJWTToken = _apiClient.GenerateWebSocketToken();

var parsedPrivateKey = _apiClient.ParseKey(privateKey);

Assert.IsTrue(IsTokenValid(generateWebSocketJWTToken, name, parsedPrivateKey));
}

/// <summary>
/// Validates a JWT token using ECDsa key with the specified token ID and secret.
/// </summary>
/// <param name="token">The JWT token to be validated.</param>
/// <param name="tokenId">The unique identifier for the ECDsa security key.</param>
/// <param name="parsedPrivateKey">The ECDsa private key in Base64 format used to validate the token's signature.</param>
/// <returns>
/// <c>true</c> if the token is successfully validated; otherwise, <c>false</c>.
/// </returns>
/// <remarks>
/// This method is useful for verifying the authenticity of JWT tokens using ECDsa keys.
/// It ensures that the token's signature matches the expected signature derived from the provided secret.
/// </remarks>
private bool IsTokenValid(string token, string tokenId, string parsedPrivateKey)
{
if (token == null)
return false;

var key = ECDsa.Create();
key?.ImportECPrivateKey(Convert.FromBase64String(parsedPrivateKey), out _);

var securityKey = new ECDsaSecurityKey(key) { KeyId = tokenId };

try
{
var tokenHandler = new JwtSecurityTokenHandler();
tokenHandler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = securityKey,
ValidateIssuer = false,
ValidateAudience = false,
ClockSkew = TimeSpan.FromSeconds(100),
ValidateLifetime = true,
LifetimeValidator = CustomLifetimeValidator,
}, out var validatedToken);

return true;
}
catch
{
return false;
}
}

/// <summary>
/// Custom validator for checking the token's lifetime.
/// </summary>
/// <param name="notBefore">The 'Not Before' date/time from the token's claims.</param>
/// <param name="expires">The expiration date/time from the token's claims.</param>
/// <param name="tokenToValidate">The security token being validated.</param>
/// <param name="param">The token validation parameters.</param>
/// <returns>
/// <c>true</c> if the token is valid based on its expiration time; otherwise, <c>false</c>.
/// </returns>
/// <remarks>
/// This custom lifetime validator ensures that the JWT token has not expired. It compares the
/// token's expiration time against the current UTC time to determine its validity.
/// </remarks>
private static bool CustomLifetimeValidator(DateTime? notBefore, DateTime? expires, SecurityToken tokenToValidate, TokenValidationParameters @param)
{
if (expires != null)
{
return expires > DateTime.UtcNow;
}
return false;
}

private CoinbaseApi CreateCoinbaseApi(string name, string privateKey)
{
var restApiUrl = Config.Get("coinbase-rest-api", "https://api.coinbase.com");

return new CoinbaseApi(new SymbolPropertiesDatabaseSymbolMapper(Market.Coinbase), null, apiKey, apiKeySecret, restApiUrl);
return new CoinbaseApi(new SymbolPropertiesDatabaseSymbolMapper(Market.Coinbase), null, name, privateKey, restApiUrl);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,11 @@ private static CoinbaseBrokerage GetBrokerage()
{
var wssUrl = Config.Get("coinbase-url", "wss://advanced-trade-ws.coinbase.com");
var restApiUrl = Config.Get("coinbase-rest-api", "https://api.coinbase.com");
var apiKey = Config.Get("coinbase-api-key");
var apiSecret = Config.Get("coinbase-api-secret");
var name = Config.Get("coinbase-api-name");
var privateKey = Config.Get("coinbase-api-private-key");
var algorithm = new QCAlgorithm();
var aggregator = new AggregationManager();

return new CoinbaseBrokerage(wssUrl, apiKey, apiSecret, restApiUrl, algorithm, aggregator, null);
return new CoinbaseBrokerage(wssUrl, name, privateKey, restApiUrl, algorithm, null);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@
using NUnit.Framework;
using System.Threading;
using QuantConnect.Data;
using QuantConnect.Tests;
using QuantConnect.Logging;
using Microsoft.CodeAnalysis;
using QuantConnect.Data.Market;
using System.Collections.Generic;
using QuantConnect.Tests;
using System.Collections.Concurrent;

namespace QuantConnect.Brokerages.Coinbase.Tests
{
Expand All @@ -46,7 +47,7 @@ private static IEnumerable<TestCaseData> TestParameters
yield return new TestCaseData(BTCUSDC, Resolution.Second);
yield return new TestCaseData(BTCUSDC, Resolution.Minute);
yield return new TestCaseData(Symbol.Create("ETHUSD", SecurityType.Crypto, Market.Coinbase), Resolution.Minute);
yield return new TestCaseData(Symbol.Create("GRTUSD", SecurityType.Crypto, Market.Coinbase), Resolution.Second);
yield return new TestCaseData(Symbol.Create("SOLUSD", SecurityType.Crypto, Market.Coinbase), Resolution.Second);
}
}

Expand Down Expand Up @@ -193,7 +194,9 @@ public void SubscribeOnMultipleSymbols(List<SubscriptionDataConfig> liquidSymbol
{
var cancelationToken = new CancellationTokenSource();
var startTime = DateTime.UtcNow;
var obj = new object();
var tickResetEvent = new ManualResetEvent(false);
var minimumAvailableReturnResponseData = liquidSymbolsSubscriptionConfigs.Count / 2;

_brokerage.Message += (_, brokerageMessageEvent) =>
{
Expand All @@ -202,7 +205,7 @@ public void SubscribeOnMultipleSymbols(List<SubscriptionDataConfig> liquidSymbol
cancelationToken.Cancel();
};

var symbolTicks = new Dictionary<Symbol, bool>();
var symbolTicks = new ConcurrentDictionary<Symbol, bool>();
foreach (var config in liquidSymbolsSubscriptionConfigs)
{
ProcessFeed(_brokerage.Subscribe(config, (s, e) => { }),
Expand All @@ -218,14 +221,18 @@ public void SubscribeOnMultipleSymbols(List<SubscriptionDataConfig> liquidSymbol
Assert.IsTrue(tick.Price > 0, "Price was not greater then zero");
Assert.IsTrue(tick.Value > 0, "Value was not greater then zero");

if (!symbolTicks.TryGetValue(tick.Symbol, out var symbol))
lock (obj)
{
symbolTicks[tick.Symbol] = true;
}
if (!symbolTicks.TryGetValue(tick.Symbol, out var symbol))
{
_brokerage.Unsubscribe(config);
symbolTicks[tick.Symbol] = true;
}

if (symbolTicks.Count == liquidSymbolsSubscriptionConfigs.Count / 2)
{
tickResetEvent.Set();
if (symbolTicks.Count == minimumAvailableReturnResponseData)
{
tickResetEvent.Set();
}
}
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
using QuantConnect.Data.Market;
using System.Collections.Generic;
using QuantConnect.Configuration;
using QuantConnect.Lean.Engine.DataFeeds;

namespace QuantConnect.Brokerages.Coinbase.Tests
{
Expand All @@ -36,11 +35,10 @@ public void GetsHistory(Symbol symbol, Resolution resolution, TickType tickType,
{
var brokerage = new CoinbaseBrokerage(
Config.Get("coinbase-url", "wss://advanced-trade-ws.coinbase.com"),
Config.Get("coinbase-api-key"),
Config.Get("coinbase-api-secret"),
Config.Get("coinbase-api-name"),
Config.Get("coinbase-api-private-key"),
Config.Get("coinbase-rest-api", "https://api.coinbase.com"),
null,
new AggregationManager(),
null);

var now = DateTime.UtcNow;
Expand Down
Loading

0 comments on commit 7c2b562

Please sign in to comment.