From c5485f7a13b87a1fb8ff0d6526b5cdb31ac0e1d6 Mon Sep 17 00:00:00 2001 From: tr00d Date: Thu, 7 Nov 2024 14:02:57 +0100 Subject: [PATCH] feat: implement JWT tokens by default, T1 are still available with 'GenerateT1Token' --- OpenTok/OpenTok.cs | 51 ++++++++++++++++++ OpenTok/OpenTok.csproj | 1 + OpenTok/Session.cs | 44 ++++++++++++++- OpenTok/Util/HttpClient.cs | 37 +++++++++++++ OpenTok/Util/OpenTokUtils.cs | 2 +- OpenTok/Util/TokenGenerator.cs | 88 ++++++++++++++++++++++++++++++ OpenTokTest/OpenTokTest.csproj | 1 + OpenTokTest/TokenTests.cs | 97 ++++++++++++++++++++++++++++++---- 8 files changed, 308 insertions(+), 13 deletions(-) create mode 100644 OpenTok/Util/TokenGenerator.cs diff --git a/OpenTok/OpenTok.cs b/OpenTok/OpenTok.cs index edbd622e..9a348a71 100644 --- a/OpenTok/OpenTok.cs +++ b/OpenTok/OpenTok.cs @@ -396,6 +396,57 @@ public string GenerateToken(string sessionId, Role role = Role.PUBLISHER, double Session session = new Session(sessionId, ApiKey, ApiSecret); return session.GenerateToken(role, expireTime, data, initialLayoutClassList); } + + /// + /// Creates a token for connecting to an OpenTok session. In order to authenticate a user + /// connecting to an OpenTok session, the client passes a token when connecting to the session. + /// + /// For testing, you can also generate test tokens by logging in to your + /// TokBox account. + /// + /// + /// + /// The session ID corresponding to the session to which the user will connect. + /// + /// + /// The role for the token. Valid values are defined in the Role enum: + /// - (A subscriber can only subscribe to streams) + /// - (A publisher can publish streams, subscribe to streams, and signal. + /// (This is the default value if you do not specify a role.)) + /// - (In addition to the privileges granted to a publisher, + /// a moderator can perform moderation functions, such as forcing clients + /// to disconnect, to stop publishing streams, or to mute audio in published streams. See the + /// Moderation developer guide. + /// + /// + /// The expiration time of the token, in seconds since the UNIX epoch. Pass in 0 to use the default + /// expiration time of 24 hours after the token creation time. The maximum expiration time is 30 days + /// after the creation time. + /// + /// + /// A string containing connection metadata describing the end-user. For example, you can pass the + /// user ID, name, or other data describing the end-user. The length of the string is limited to 1000 + /// characters. This data cannot be updated once it is set. + /// + /// + /// A list of strings values containing the initial layout for the stream. + /// + /// + public string GenerateT1Token(string sessionId, Role role = Role.PUBLISHER, double expireTime = 0, string data = null, List initialLayoutClassList = null) + { + if (String.IsNullOrEmpty(sessionId)) + { + throw new OpenTokArgumentException("Session id cannot be empty or null"); + } + + if (!OpenTokUtils.ValidateSession(sessionId)) + { + throw new OpenTokArgumentException("Invalid Session id " + sessionId); + } + + Session session = new Session(sessionId, ApiKey, ApiSecret); + return session.GenerateT1Token(role, expireTime, data, initialLayoutClassList); + } /// /// Starts archiving an OpenTok session. diff --git a/OpenTok/OpenTok.csproj b/OpenTok/OpenTok.csproj index aaf03260..d549705d 100644 --- a/OpenTok/OpenTok.csproj +++ b/OpenTok/OpenTok.csproj @@ -29,6 +29,7 @@ + diff --git a/OpenTok/Session.cs b/OpenTok/Session.cs index 8007f873..043f521f 100644 --- a/OpenTok/Session.cs +++ b/OpenTok/Session.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; using System.Linq; +using System.Security.Cryptography; using System.Text; using System.Web; - +using Microsoft.IdentityModel.Tokens; using OpenTokSDK.Util; using OpenTokSDK.Exception; @@ -131,7 +133,7 @@ internal Session(string sessionId, int apiKey, string apiSecret, string location /// /// /// The token string. - public string GenerateToken(Role role = Role.PUBLISHER, double expireTime = 0, string data = null, List initialLayoutClassList = null) + public string GenerateT1Token(Role role = Role.PUBLISHER, double expireTime = 0, string data = null, List initialLayoutClassList = null) { double createTime = OpenTokUtils.GetCurrentUnixTimeStamp(); int nonce = OpenTokUtils.GetRandomNumber(); @@ -139,6 +141,44 @@ public string GenerateToken(Role role = Role.PUBLISHER, double expireTime = 0, s string dataString = BuildDataString(role, expireTime, data, createTime, nonce, initialLayoutClassList); return BuildTokenString(dataString); } + + /// + /// Creates a token for connecting to an OpenTok session. In order to authenticate a user + /// connecting to an OpenTok session that user must pass an authentication token along with + /// the API key. + /// + /// + /// The role for the token. Valid values are defined in the Role enum: + /// - A subscriber can only subscribe to streams. + /// - A publisher can publish streams, subscribe to + /// streams, and signal. (This is the default value if you do not specify a role.) + /// - In addition to the privileges granted to a + /// publisher, in clients using the OpenTok.js library, a moderator can call the + /// forceUnpublish() and forceDisconnect() method of the Session object. + /// + /// + /// The expiration time of the token, in seconds since the UNIX epoch. + /// Pass in 0 to use the default expiration time of 24 hours after the token creation time. + /// The maximum expiration time is 30 days after the creation time. + /// + /// + /// A string containing connection metadata describing the end-user. For example, + /// you can pass the user ID, name, or other data describing the end-user. The length of the + /// string is limited to 1000 characters. This data cannot be updated once it is set. + /// + /// + /// The token string. + public string GenerateToken(Role role = Role.PUBLISHER, double expireTime = 0, string data = null, List initialLayoutClassList = null) => + new TokenGenerator().GenerateToken(new TokenData() + { + ApiSecret = this.ApiSecret, + Role = role, + ApiKey = this.ApiKey.ToString(), + Data = data, + SessionId = this.Id, + ExpireTime = expireTime, + InitialLayoutClasses = initialLayoutClassList ?? Enumerable.Empty(), + }); private string BuildTokenString(string dataString) { diff --git a/OpenTok/Util/HttpClient.cs b/OpenTok/Util/HttpClient.cs index c1d8618d..e5ac3381 100644 --- a/OpenTok/Util/HttpClient.cs +++ b/OpenTok/Util/HttpClient.cs @@ -1,14 +1,18 @@ using System; using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; using System.IO; using System.Linq; using System.Net; +using System.Security.Cryptography; +using System.Text; using System.Threading.Tasks; using System.Web; using System.Xml; using JWT; using JWT.Algorithms; using JWT.Serializers; +using Microsoft.IdentityModel.Tokens; using Newtonsoft.Json; using OpenTokSDK.Constants; using OpenTokSDK.Exception; @@ -397,6 +401,39 @@ private string GenerateJwt(int key, string secret, int expiryPeriod = 300) var token = encoder.Encode(payload, secret); return token; } + + private string GenerateJwt2(int apiKey, string apiSecret, int expiryPeriod = 300) + { + var tokenData = new byte[64]; + var rng = RandomNumberGenerator.Create(); + rng.GetBytes(tokenData); + var jwtTokenId = Convert.ToBase64String(tokenData); + var payload = new Dictionary + { + {"iss", apiKey}, + {"ist", "project"}, + {"role", "publisher"}, + {"session_id", "sessionid"}, + {"scope", "session.connect"}, + {"iat", (long) (DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalSeconds}, + {"exp", (long) (DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalSeconds + 300}, + {"jti", jwtTokenId}, + {"initial_layout_list", ""}, + {"nonce", OpenTokUtils.GetRandomNumber()}, + }; + // generate token that is valid for 7 days + var tokenHandler = new JwtSecurityTokenHandler(); + var key = Encoding.ASCII.GetBytes(apiSecret); + var tokenDescriptor = new SecurityTokenDescriptor + { + //Expires = DateTime.UtcNow.AddDays(7), + SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256), + Claims = payload, + }; + var token = tokenHandler.CreateToken(tokenDescriptor); + var a = tokenHandler.WriteToken(token); + return a; + } private Dictionary GetCommonHeaders() { diff --git a/OpenTok/Util/OpenTokUtils.cs b/OpenTok/Util/OpenTokUtils.cs index 7c7888f5..c3b8b421 100644 --- a/OpenTok/Util/OpenTokUtils.cs +++ b/OpenTok/Util/OpenTokUtils.cs @@ -65,7 +65,7 @@ public static string Convert64(string input) } public static double GetUnixTimeStampForDate(DateTime date) { - return (date - new DateTime(1970, 1, 1, 0, 0, 0)).TotalSeconds; + return (date.ToUniversalTime() - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalSeconds; } public static double GetCurrentUnixTimeStamp() diff --git a/OpenTok/Util/TokenGenerator.cs b/OpenTok/Util/TokenGenerator.cs new file mode 100644 index 00000000..fa9da6d8 --- /dev/null +++ b/OpenTok/Util/TokenGenerator.cs @@ -0,0 +1,88 @@ +#region + +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Cryptography; +using System.Text; +using Microsoft.IdentityModel.Tokens; +using OpenTokSDK.Exception; + +#endregion + +namespace OpenTokSDK.Util +{ + internal class TokenGenerator + { + public string GenerateToken(TokenData data) + { + var tokenHandler = new JwtSecurityTokenHandler(); + var tokenDescriptor = new SecurityTokenDescriptor + { + SigningCredentials = data.GenerateCredentials(), + Claims = data.GeneratePayload(), + Expires = data.GetExpireTime().UtcDateTime + }; + var token = tokenHandler.CreateToken(tokenDescriptor); + return tokenHandler.WriteToken(token); + } + } + + internal struct TokenData + { + public string ApiKey { get; set; } + public string ApiSecret { get; set; } + public string SessionId { get; set; } + public Role Role { get; set; } + public double ExpireTime { get; set; } + public string Data { get; set; } + public IEnumerable InitialLayoutClasses { get; set; } + + public DateTimeOffset GetExpireTime() + { + return CheckExpireTime(ExpireTime, new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds()) + ? DateTimeOffset.FromUnixTimeSeconds((long)ExpireTime) + : new DateTimeOffset(DateTime.UtcNow.AddSeconds(300)); + } + + internal Dictionary GeneratePayload() + { + var creationTime = new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds(); + var payload = new Dictionary + { + { "iss", ApiKey }, + { "ist", "project" }, + { "role", Role.ToString().ToLowerInvariant() }, + { "session_id", SessionId }, + { "scope", "session.connect" }, + { "iat", creationTime }, + { "jti", GenerateTokenId() }, + { "initial_layout_list", string.Join(" ", InitialLayoutClasses) }, + { "nonce", OpenTokUtils.GetRandomNumber() } + }; + + return payload; + } + + private static bool CheckExpireTime(double expireTime, double createTime) + { + if (expireTime == 0) return false; + if (expireTime > createTime && expireTime <= OpenTokUtils.GetCurrentUnixTimeStamp() + 2592000) return true; + throw new OpenTokArgumentException( + $"Invalid expiration time for token {expireTime}. Expiration time has to be positive and less than 30 days"); + } + + private static string GenerateTokenId() + { + var tokenData = new byte[64]; + RandomNumberGenerator.Create().GetBytes(tokenData); + return Convert.ToBase64String(tokenData); + } + + internal SigningCredentials GenerateCredentials() + { + var key = Encoding.ASCII.GetBytes(ApiSecret); + return new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256); + } + } +} \ No newline at end of file diff --git a/OpenTokTest/OpenTokTest.csproj b/OpenTokTest/OpenTokTest.csproj index 62cf971a..743fa1c4 100644 --- a/OpenTokTest/OpenTokTest.csproj +++ b/OpenTokTest/OpenTokTest.csproj @@ -290,6 +290,7 @@ + diff --git a/OpenTokTest/TokenTests.cs b/OpenTokTest/TokenTests.cs index d66f77ad..ba1c9c84 100644 --- a/OpenTokTest/TokenTests.cs +++ b/OpenTokTest/TokenTests.cs @@ -1,5 +1,12 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Security.Claims; +using System.Text; +using FluentAssertions; +using Microsoft.IdentityModel.Tokens; using Xunit; using OpenTokSDK; using OpenTokSDK.Util; @@ -7,7 +14,7 @@ namespace OpenTokSDKTest { - public class TokenTests : TestBase + public class T1TokenTests : TestBase { [Fact] public void GenerateTokenTest() @@ -15,7 +22,7 @@ public void GenerateTokenTest() OpenTok opentok = new OpenTok(ApiKey, ApiSecret); String sessionId = "1_MX4xMjM0NTZ-flNhdCBNYXIgMTUgMTQ6NDI6MjMgUERUIDIwMTR-MC40OTAxMzAyNX4"; - string token = opentok.GenerateToken(sessionId); + string token = opentok.GenerateT1Token(sessionId); Assert.NotNull(token); var data = CheckToken(token); @@ -37,7 +44,7 @@ public void GenerateTokenWithRoleTest(Role role, string expectedRole) OpenTok opentok = new OpenTok(ApiKey, ApiSecret); String sessionId = "1_MX4xMjM0NTZ-flNhdCBNYXIgMTUgMTQ6NDI6MjMgUERUIDIwMTR-MC40OTAxMzAyNX4"; - string token = opentok.GenerateToken(sessionId, role: role); + string token = opentok.GenerateT1Token(sessionId, role: role); Assert.NotNull(token); var data = CheckToken(token); @@ -56,7 +63,7 @@ public void GenerateTokenWithExpireTimeTest() double expireTime = OpenTokUtils.GetCurrentUnixTimeStamp() + 10; String sessionId = "1_MX4xMjM0NTZ-flNhdCBNYXIgMTUgMTQ6NDI6MjMgUERUIDIwMTR-MC40OTAxMzAyNX4"; - string token = opentok.GenerateToken(sessionId, expireTime: expireTime); + string token = opentok.GenerateT1Token(sessionId, expireTime: expireTime); Assert.NotNull(token); var data = CheckToken(token); @@ -75,7 +82,7 @@ public void GenerateTokenWithConnectionDataTest() OpenTok opentok = new OpenTok(ApiKey, ApiSecret); string connectionData = "Somedatafortheconnection"; String sessionId = "1_MX4xMjM0NTZ-flNhdCBNYXIgMTUgMTQ6NDI6MjMgUERUIDIwMTR-MC40OTAxMzAyNX4"; - string token = opentok.GenerateToken(sessionId, data:connectionData); + string token = opentok.GenerateT1Token(sessionId, data:connectionData); Assert.NotNull(token); var data = CheckToken(token); @@ -96,7 +103,7 @@ public void GenerateTokenWithInitialLayoutClass() String sessionId = "1_MX4xMjM0NTZ-flNhdCBNYXIgMTUgMTQ6NDI6MjMgUERUIDIwMTR-MC40OTAxMzAyNX4"; List initalLayoutClassList = new List(); initalLayoutClassList.Add("focus"); - string token = opentok.GenerateToken(sessionId, initialLayoutClassList: initalLayoutClassList); + string token = opentok.GenerateT1Token(sessionId, initialLayoutClassList: initalLayoutClassList); Assert.NotNull(token); var data = CheckToken(token); @@ -113,10 +120,10 @@ public void GenerateTokenWithInitialLayoutClass() public void GenerateInvalidTokensTest() { OpenTok opentok = new OpenTok(ApiKey, ApiSecret); - Assert.Throws(() => opentok.GenerateToken(null)); - Assert.Throws(() => opentok.GenerateToken("")); - Assert.Throws(() => opentok.GenerateToken(string.Empty)); - Assert.Throws(() => opentok.GenerateToken("NOT A VALID SESSION ID")); + Assert.Throws(() => opentok.GenerateT1Token(null)); + Assert.Throws(() => opentok.GenerateT1Token("")); + Assert.Throws(() => opentok.GenerateT1Token(string.Empty)); + Assert.Throws(() => opentok.GenerateT1Token("NOT A VALID SESSION ID")); } @@ -134,5 +141,75 @@ private Dictionary CheckToken(string token) return tokenData; } } + + public class TokenTests + { + private const string ApiSecret = "1234567890abcdef1234567890abcdef1234567890"; + private const int ApiKey = 123456; + private const string SessionId = "1_MX4xMjM0NTZ-flNhdCBNYXIgMTUgMTQ6NDI6MjMgUERUIDIwMTR-MC40OTAxMzAyNX4"; + private readonly OpenTok sut = new(ApiKey, ApiSecret); + + [Fact] + public void GenerateToken_ShouldReturnTokenWithDefaultValues() + { + var token = sut.GenerateToken(SessionId); + var claims = ExtractClaims(token); + claims["iat"].Should().NotBeEmpty(); + claims["exp"].Should().NotBeEmpty(); + claims["jti"].Should().NotBeEmpty(); + claims["nonce"].Should().NotBeEmpty(); + claims.ContainsKey("exp").Should().BeTrue(); + claims["iss"].Should().Be(ApiKey.ToString()); + claims["ist"].Should().Be("project"); + claims["scope"].Should().Be("session.connect"); + claims["session_id"].Should().Be(SessionId); + claims["role"].Should().Be("publisher"); + claims.ContainsKey("initial_layout_class_list").Should().BeFalse(); + } + + [Theory] + [InlineData(Role.SUBSCRIBER, "subscriber")] + [InlineData(Role.PUBLISHER, "publisher")] + [InlineData(Role.MODERATOR, "moderator")] + [InlineData(Role.PUBLISHERONLY, "publisheronly")] + public void GenerateToken_ShouldSetRole(Role role, string expectedRole) + { + var token = sut.GenerateToken(SessionId, role); + var claims = ExtractClaims(token); + claims["role"].Should().Be(expectedRole); + } + + [Fact] + public void GenerateToken_ShouldSetExpireTime() + { + var expireTime = new DateTimeOffset(DateTime.UtcNow.AddSeconds(30)).ToUnixTimeSeconds(); + var token = sut.GenerateToken(SessionId, expireTime: expireTime); + var claims = ExtractClaims(token); + claims["exp"].Should().Be(expireTime.ToString(CultureInfo.InvariantCulture)); + } + + [Fact] + public void GenerateToken_ShouldSetInitialLayoutClass() + { + var list = new List { "focus", "hello" }; + var token = sut.GenerateToken(SessionId, initialLayoutClassList: list); + var claims = ExtractClaims(token); + claims["initial_layout_list"].Should().Be("focus hello"); + } + + private static Dictionary ExtractClaims(string token) + { + var key = Encoding.ASCII.GetBytes(ApiSecret); + new JwtSecurityTokenHandler().ValidateToken(token, new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(key), + ValidateIssuer = false, + ValidateAudience = false, + }, out SecurityToken validatedToken); + var jwtToken = (JwtSecurityToken)validatedToken; + return jwtToken.Claims.ToDictionary(claim => claim.Type, claim => claim.Value);; + } + } }