From 0d3d02033e1492d244484e25c38ac8f7ac53bd0c Mon Sep 17 00:00:00 2001 From: Warren Parad Date: Sat, 20 Jan 2024 21:46:25 +0100 Subject: [PATCH 1/3] Refactor accessKey class location. --- CHANGELOG.md | 2 ++ contributing.md | 1 + src/Authress.SDK/Client/AuthressClient.cs | 9 --------- src/Authress.SDK/Client/AuthressClientTokenProvider.cs | 9 +++++++++ 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6151b11..54564bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ This is the changelog for [Authress SDK](readme.md). ## 2.0 ## * Renamed `AccessRecordStatements` and other models that end with `S` but aren't actually plural to be `AccessRecordStatement` (without the `S`). * All APIs are now part of sub instance properties of the `AuthressClient` class, `AccessClient.AccessRecords` and `AccessClient.ServiceClients`, etc.. +* `ApiBasePath` has been renamed to `AuthressApiUrl`. +* `HttpClientSettings` Has been removed in favor of `AuthressSettings` Class. ## 1.5 ## * Fix `DateTimeOffset` type assignments, properties that were incorrectly defined as `DateTime` are now correctly `DateTimeOffsets`. diff --git a/contributing.md b/contributing.md index fb746bb..3ae5b09 100644 --- a/contributing.md +++ b/contributing.md @@ -60,3 +60,4 @@ Update the API Key: https://www.nuget.org/account/apikeys using the Rhosys Devel * [ ] Remove LocalHost from the docs * [ ] Tests * [x] If-unmodified-since should called `expectedLastModifiedTime`, accept string or dateTime and convert this to an ISO String +* [ ] Update OAuth2 openapi authentication references in the documentation diff --git a/src/Authress.SDK/Client/AuthressClient.cs b/src/Authress.SDK/Client/AuthressClient.cs index 6707ccd..0857620 100644 --- a/src/Authress.SDK/Client/AuthressClient.cs +++ b/src/Authress.SDK/Client/AuthressClient.cs @@ -42,13 +42,4 @@ public AuthressClient(ITokenProvider tokenProvider, HttpClientSettings settings, tokenProvider, customHttpClientHandlerFactory); } } - - internal class AccessKey - { - public String Audience { get; set; } - public String ClientId { get; set; } - public String KeyId { get; set; } - public String PrivateKey { get; set; } - public String Algorithm { get; set; } = "RS256"; - } } diff --git a/src/Authress.SDK/Client/AuthressClientTokenProvider.cs b/src/Authress.SDK/Client/AuthressClientTokenProvider.cs index c3e7f93..8cd0fff 100644 --- a/src/Authress.SDK/Client/AuthressClientTokenProvider.cs +++ b/src/Authress.SDK/Client/AuthressClientTokenProvider.cs @@ -16,6 +16,15 @@ namespace Authress.SDK { + internal class AccessKey + { + public String Audience { get; set; } + public String ClientId { get; set; } + public String KeyId { get; set; } + public String PrivateKey { get; set; } + public String Algorithm { get; set; } = "RS256"; + } + /// /// Provides the token from locally stored access key. Access key can be retrieved when creating a new client in the Authress UI. /// From 7a8ea78660643c1e8dd6df75939386d99930d350 Mon Sep 17 00:00:00 2001 From: Warren Parad Date: Sun, 21 Jan 2024 17:05:15 +0100 Subject: [PATCH 2/3] Add verifyToken method. fix #27 --- README.md | 18 +- src/Authress.SDK/Client/AuthressClient.cs | 15 ++ .../Client/AuthressClientTokenProvider.cs | 6 +- src/Authress.SDK/Client/HttpClientProvider.cs | 8 +- src/Authress.SDK/Client/JWT/JwtPayload.cs | 23 ++ .../Client/OptimisticPerformanceHandler.cs | 8 +- src/Authress.SDK/Client/TokenVerifier.cs | 226 ++++++++++++++++++ src/Authress.SDK/Utilities/Sanitizers.cs | 22 ++ 8 files changed, 313 insertions(+), 13 deletions(-) create mode 100644 src/Authress.SDK/Client/JWT/JwtPayload.cs create mode 100644 src/Authress.SDK/Client/TokenVerifier.cs create mode 100644 src/Authress.SDK/Utilities/Sanitizers.cs diff --git a/README.md b/README.md index 8d5f38d..e810573 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,20 @@ Installation: * run: `dotnet add Authress.SDK` (or install via visual tools) +#### Verify Authress JWT +The recommended solution is to use the C# built in OpenID provider by Microsoft. An example implementation is available in the [Authress C# Starter Kit](https://github.com/Authress/csharp-starter-kit/blob/main/src/Program.cs#L35). However, in some cases you might need to parse the JWT directly and verify it for use in serverless functions. + +```csharp +using Authress.SDK; + +// Get an authress custom domain: https://authress.io/app/#/settings?focus=domain +var authressSettings = new AuthressSettings { ApiBasePath = "https://authress.company.com", }; +var authressClient = new AuthressClient(tokenProvider, authressSettings) + +var verifiedUserIdentity = await authressClient.VerifyToken(jwtToken); +Console.WriteLine($"User ID: {verifiedUserIdentity.UserId}"); +``` + #### Authorize users using user identity token ```csharp using Authress.SDK; @@ -46,7 +60,7 @@ namespace Microservice return accessToken; }); // Get an authress custom domain: https://authress.io/app/#/settings?focus=domain - var authressSettings = new AuthressSettings { ApiBasePath = "https://CUSTOM_DOMAIN.application.com", }; + var authressSettings = new AuthressSettings { ApiBasePath = "https://authress.company.com", }; var authressClient = new AuthressClient(tokenProvider, authressSettings); // 2. At runtime attempt to Authorize the user for the resource @@ -103,7 +117,7 @@ namespace Microservice var decodedAccessKey = decrypt(accessKey); var tokenProvider = new AuthressClientTokenProvider(decodedAccessKey); // Get an authress custom domain: https://authress.io/app/#/settings?focus=domain - var authressSettings = new AuthressSettings { ApiBasePath = "https://CUSTOM_DOMAIN.application.com", }; + var authressSettings = new AuthressSettings { ApiBasePath = "https://authress.company.com", }; var authressClient = new AuthressClient(tokenProvider, authressSettings); // Attempt to Authorize the user for the resource diff --git a/src/Authress.SDK/Client/AuthressClient.cs b/src/Authress.SDK/Client/AuthressClient.cs index 0857620..6b54a78 100644 --- a/src/Authress.SDK/Client/AuthressClient.cs +++ b/src/Authress.SDK/Client/AuthressClient.cs @@ -17,6 +17,7 @@ namespace Authress.SDK public partial class AuthressClient { private readonly HttpClientProvider authressHttpClientProvider; + private readonly TokenVerifier tokenVerifier; /// /// Get the permissions a user has to a resource. Get a summary of the permissions a user has to a particular resource. @@ -27,6 +28,7 @@ public AuthressClient(ITokenProvider tokenProvider, AuthressSettings settings, I throw new ArgumentNullException("Missing required parameter AuthressSettings"); } authressHttpClientProvider = new HttpClientProvider(settings, tokenProvider, customHttpClientHandlerFactory); + tokenVerifier = new TokenVerifier(settings.ApiBasePath, authressHttpClientProvider); } /// @@ -40,6 +42,19 @@ public AuthressClient(ITokenProvider tokenProvider, HttpClientSettings settings, authressHttpClientProvider = new HttpClientProvider( new AuthressSettings { ApiBasePath = settings?.ApiBasePath, RequestTimeout = settings?.RequestTimeout }, tokenProvider, customHttpClientHandlerFactory); + tokenVerifier = new TokenVerifier(settings.ApiBasePath, authressHttpClientProvider); } + + /// + /// Verify a JWT token from anywhere. If it is valid a VerifiedUserIdentity will be returned. If it is invalid an exception will be thrown. + /// + /// + /// A verified user identity that contains the user's ID + /// Token is invalid in some way + /// One of the required parameters for this function was not specified. + public async Task VerifyToken(string jwtToken) { + return await tokenVerifier.VerifyToken(jwtToken); + } + } } diff --git a/src/Authress.SDK/Client/AuthressClientTokenProvider.cs b/src/Authress.SDK/Client/AuthressClientTokenProvider.cs index 8cd0fff..4798ece 100644 --- a/src/Authress.SDK/Client/AuthressClientTokenProvider.cs +++ b/src/Authress.SDK/Client/AuthressClientTokenProvider.cs @@ -56,8 +56,10 @@ public AuthressClientTokenProvider(string accessKeyBase64, string authressCustom this.accessKey = new AccessKey { Algorithm = "EdDSA", - ClientId = accessKeyBase64.Split('.')[0], KeyId = accessKeyBase64.Split('.')[1], - Audience = $"{accountId}.accounts.authress.io", PrivateKey = accessKeyBase64.Split('.')[3] + ClientId = accessKeyBase64.Split('.')[0], + KeyId = accessKeyBase64.Split('.')[1], + Audience = $"{accountId}.accounts.authress.io", + PrivateKey = accessKeyBase64.Split('.')[3] }; this.resolvedAuthressCustomDomain = (authressCustomDomain ?? $"{accountId}.api.authress.io").Replace("https://", ""); diff --git a/src/Authress.SDK/Client/HttpClientProvider.cs b/src/Authress.SDK/Client/HttpClientProvider.cs index 86cf026..4eeb92c 100644 --- a/src/Authress.SDK/Client/HttpClientProvider.cs +++ b/src/Authress.SDK/Client/HttpClientProvider.cs @@ -17,12 +17,12 @@ public interface IHttpClientHandlerFactory } /// - /// Authress Domain Host: https://CUSTOM_DOMAIN.application.com (Get an authress custom domain: https://authress.io/app/#/settings?focus=domain) + /// Authress Domain Host: https://authress.company.com (Get an authress custom domain: https://authress.io/app/#/settings?focus=domain) /// public class HttpClientSettings { /// - /// Authress Domain Host: https://CUSTOM_DOMAIN.application.com (Get an authress custom domain: https://authress.io/app/#/settings?focus=domain) + /// Authress Domain Host: https://authress.company.com (Get an authress custom domain: https://authress.io/app/#/settings?focus=domain) /// public string ApiBasePath { get; set; } = "https://api.authress.io"; @@ -33,12 +33,12 @@ public class HttpClientSettings } /// - /// Authress Domain Host: https://CUSTOM_DOMAIN.application.com (Get an authress custom domain: https://authress.io/app/#/settings?focus=domain) + /// Authress Domain Host: https://authress.company.com (Get an authress custom domain: https://authress.io/app/#/settings?focus=domain) /// public class AuthressSettings { /// - /// Authress Domain Host: https://CUSTOM_DOMAIN.application.com (Get an authress custom domain: https://authress.io/app/#/settings?focus=domain) + /// Authress Domain Host: https://authress.company.com (Get an authress custom domain: https://authress.io/app/#/settings?focus=domain) /// public string ApiBasePath { get; set; } = "https://api.authress.io"; diff --git a/src/Authress.SDK/Client/JWT/JwtPayload.cs b/src/Authress.SDK/Client/JWT/JwtPayload.cs new file mode 100644 index 0000000..96883c7 --- /dev/null +++ b/src/Authress.SDK/Client/JWT/JwtPayload.cs @@ -0,0 +1,23 @@ +using System.Runtime.Serialization; + +namespace Authress.SDK.Client.JWT +{ + [DataContract] + internal class JwtPayload + { + + [DataMember(Name = "sub")] + internal string Subject { get; set; } + + [DataMember(Name = "iss")] + internal string Issuer { get; set; } + } + + [DataContract] + internal class JwtHeader + { + + [DataMember(Name = "kid")] + internal string KeyId { get; set; } + } +} diff --git a/src/Authress.SDK/Client/OptimisticPerformanceHandler.cs b/src/Authress.SDK/Client/OptimisticPerformanceHandler.cs index c775352..75d6338 100644 --- a/src/Authress.SDK/Client/OptimisticPerformanceHandler.cs +++ b/src/Authress.SDK/Client/OptimisticPerformanceHandler.cs @@ -5,15 +5,13 @@ using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using Authress.SDK.Client.JWT; using Microsoft.Extensions.Caching.Memory; using Microsoft.IdentityModel.Tokens; using Newtonsoft.Json; namespace Authress.SDK.Client { - internal class JWT { - internal string Sub { get; set; } - } internal class OptimisticPerformanceHandler : DelegatingHandler { @@ -50,8 +48,8 @@ protected override async Task SendAsync(HttpRequestMessage } var jwtPayload = token.Split('.')[1]; - JWT jwt = JsonConvert.DeserializeObject(Base64UrlEncoder.Decode(jwtPayload)); - var jwtSubject = jwt.Sub; + var jwt = JsonConvert.DeserializeObject(Base64UrlEncoder.Decode(jwtPayload)); + var jwtSubject = jwt.Subject; if (string.IsNullOrEmpty(jwtSubject)) { return await base.SendAsync(request, cancellationToken); } diff --git a/src/Authress.SDK/Client/TokenVerifier.cs b/src/Authress.SDK/Client/TokenVerifier.cs new file mode 100644 index 0000000..65325a4 --- /dev/null +++ b/src/Authress.SDK/Client/TokenVerifier.cs @@ -0,0 +1,226 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Authress.SDK.Client; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.IdentityModel.Tokens; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Collections.Generic; +using System.Linq; +using System.Security.Principal; +using Newtonsoft.Json.Converters; +using System.Runtime.Serialization; +using Microsoft.Extensions.Caching.Memory; +using Authress.SDK.Utilities; +using System.Text.RegularExpressions; +using NSec.Cryptography; +using System.Text; +using Org.BouncyCastle.Utilities.Encoders; + +namespace Authress.SDK +{ + + /// + /// The verified User identity that can be trusted because it was generated by a verified access token. + /// + public class VerifiedUserIdentity { + /// + /// + /// + public string UserId { get; set; } + } + + /// + /// There was an issue with token verification and user is should be treated as Unauthorized. + /// + public class TokenVerificationException : Exception { + + internal TokenVerificationException(String message) : base(message) {} + } + + + [JsonConverter(typeof(StringEnumConverter))] + internal enum Alg { + [EnumMember(Value = "EdDSA")] EdDSA = 1, + [EnumMember(Value = "RS256")] RS256 = 2, + [EnumMember(Value = "RS512")] RSA512 = 3 + } + + [DataContract] + internal class Jwk { + [DataMember] + public string kid { get; set; } + + [DataMember(Name="alg", EmitDefaultValue=false)] + public Alg Alg { get; set; } + + [DataMember] + public string kty { get; set; } + + [DataMember] + public string crv { get; set; } + + // EdDSA + [DataMember] + public string x { get; set; } + + // RSA + [DataMember] + public string n { get; set; } + [DataMember] + public string e { get; set; } + } + + [DataContract] + internal class JwkResponse { + [DataMember(Name="keys", EmitDefaultValue=false)] + public IList Keys; + } + + internal class TokenVerifier { + private static readonly IMemoryCache jwkCache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 5000 }); + private readonly string authressCustomDomain; + private readonly HttpClientProvider authressHttpClientProvider; + + internal TokenVerifier(string authressCustomDomain, HttpClientProvider authressHttpClientProvider) { + this.authressCustomDomain = authressCustomDomain; + this.authressHttpClientProvider = authressHttpClientProvider; + } + + private async Task getPublicKey(string issuer, string kid) { + + if (jwkCache.TryGetValue(kid, out Jwk cachedJwk)) + { + if (cachedJwk != null) + { + return cachedJwk; + } + } + + JwkResponse jwkResponse; + + var path = $"{issuer}/.well-known/openid-configuration/jwks"; + var client = await authressHttpClientProvider.GetHttpClientAsync(); + using (var response = await client.GetAsync(path)) + { + await response.ThrowIfNotSuccessStatusCode(); + jwkResponse = await response.Content.ReadAsAsync(); + } + + var foundKey = jwkResponse.Keys.FirstOrDefault(k => k.kid == kid); + if (foundKey == null) { + throw new TokenVerificationException("No matching public key found for token"); + } + + jwkCache.Set(kid, foundKey, new MemoryCacheEntryOptions { + SlidingExpiration = TimeSpan.FromDays(30), + Size = 1 + }); + + return foundKey; + } + + public async Task VerifyToken(string jwtToken) { + + if (string.IsNullOrEmpty(jwtToken)) { + throw new ArgumentNullException("Unauthorized: Token not specified"); + } + + var jwtHeader = JsonConvert.DeserializeObject(Base64UrlEncoder.Decode(jwtToken.Split('.')[0])); + var unverifiedJwtPayload = JsonConvert.DeserializeObject(Base64UrlEncoder.Decode(jwtToken.Split('.')[1])); + + if (string.IsNullOrEmpty(jwtHeader.KeyId)) { + throw new TokenVerificationException("Unauthorized: No KID in token"); + } + + if (string.IsNullOrEmpty(unverifiedJwtPayload.Issuer)) { + throw new TokenVerificationException("Unauthorized: No Issuer found"); + } + + var completeIssuerUrl = new Uri(Sanitizers.SanitizeUrl(authressCustomDomain)); + try { + if (new Uri(unverifiedJwtPayload.Issuer).GetLeftPart(UriPartial.Authority) != completeIssuerUrl.GetLeftPart(UriPartial.Authority)) { + throw new TokenVerificationException($"Unauthorized: Invalid Issuer: {unverifiedJwtPayload.Issuer}"); + } + } catch (Exception) { + throw new TokenVerificationException($"Unauthorized: Invalid Issuer: {unverifiedJwtPayload.Issuer}"); + } + + // Handle service client checking + var clientIdMatcher = Regex.Match(new Uri(unverifiedJwtPayload.Issuer).AbsolutePath, @"^/v\d/clients/(?[^/]+)$"); + if (clientIdMatcher.Success && clientIdMatcher.Groups["ServiceClientId"].Value != unverifiedJwtPayload.Subject) { + throw new TokenVerificationException($"Unauthorized: Invalid Sub found for service client token: {unverifiedJwtPayload.Subject}"); + } + + var key = await getPublicKey(unverifiedJwtPayload.Issuer, jwtHeader.KeyId); + + if (key.Alg == Alg.EdDSA) { + var ed25519alg = SignatureAlgorithm.Ed25519; + + var data = Encoding.UTF8.GetBytes($"{jwtHeader}.{unverifiedJwtPayload}"); + + var keyAsString = key.x.Replace('_', '/').Replace('-', '+'); + switch(keyAsString.Length % 4) { + case 2: keyAsString += "=="; break; + case 3: keyAsString += "="; break; + } + + var edDsaPublicKey = NSec.Cryptography.PublicKey.Import(ed25519alg, Convert.FromBase64String(keyAsString), KeyBlobFormat.PkixPublicKey); + var signatureData = Encoding.UTF8.GetBytes(Base64UrlEncoder.Decode(jwtToken.Split('.')[2])); + if (!SignatureAlgorithm.Ed25519.Verify(edDsaPublicKey, data, signatureData)) { + throw new TokenVerificationException($"Unauthorized: Token Signature is not valid."); + } + + return new VerifiedUserIdentity { + UserId = unverifiedJwtPayload.Subject + }; + } + + // ELSE assume RS256 or RS512 + + var rsa = new RSACryptoServiceProvider(); + rsa.ImportParameters(new RSAParameters() + { + Modulus = Encoding.UTF8.GetBytes(Base64UrlEncoder.Decode(key.n)), + Exponent = Encoding.UTF8.GetBytes(Base64UrlEncoder.Decode(key.e)) + }); + + var tokenHandler = new JwtSecurityTokenHandler(); + var validationParameters = new TokenValidationParameters() + { + ValidateLifetime = true, + ValidateAudience = false, + ValidateIssuer = true, + ValidIssuer = unverifiedJwtPayload.Issuer, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new RsaSecurityKey(rsa) + }; + + SecurityToken validatedTokenIdentity; + IPrincipal principal; + try + { + principal = tokenHandler.ValidateToken(jwtToken, validationParameters, out validatedTokenIdentity); + } + catch (Exception exception) + { + throw new TokenVerificationException($"Unauthorized: {exception.Message}"); + } + + if (principal == null) { + throw new TokenVerificationException($"Unauthorized: Invalid token"); + } + + var verifiedJwtPayload = validatedTokenIdentity as JwtSecurityToken; + + return new VerifiedUserIdentity { + UserId = verifiedJwtPayload.Subject + }; + } + } +} diff --git a/src/Authress.SDK/Utilities/Sanitizers.cs b/src/Authress.SDK/Utilities/Sanitizers.cs new file mode 100644 index 0000000..9e6404e --- /dev/null +++ b/src/Authress.SDK/Utilities/Sanitizers.cs @@ -0,0 +1,22 @@ +using System.Text.RegularExpressions; + +namespace Authress.SDK.Utilities { + internal static class Sanitizers + { + internal static string SanitizeUrl(string urlString) { + if (string.IsNullOrEmpty(urlString)) { + return null; + } + + if (urlString.StartsWith("http")) { + return urlString; + } + + if (Regex.IsMatch(urlString, @"^localhost", RegexOptions.IgnoreCase)) { + return "http://{url}"; + } + + return $"https://{urlString}"; + } + } +} From a20718ef838880f2c34d0debd9111c48cf3695ee Mon Sep 17 00:00:00 2001 From: Warren Parad Date: Mon, 22 Jan 2024 19:46:32 +0100 Subject: [PATCH 3/3] Add unit test for VerifyToken(). fix #27. --- CHANGELOG.md | 1 + src/Authress.SDK/Client/AuthressClient.cs | 2 +- .../Client/AuthressClientTokenProvider.cs | 6 +- src/Authress.SDK/Client/HttpClientProvider.cs | 2 +- src/Authress.SDK/Client/JWT/JwtPayload.cs | 7 ++ src/Authress.SDK/Client/TokenVerifier.cs | 37 ++++++- .../Client/Tokenverifier/VerifyTokenTests.cs | 104 ++++++++++++++++++ 7 files changed, 148 insertions(+), 11 deletions(-) create mode 100644 tests/Authress.SDK/Client/Tokenverifier/VerifyTokenTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 54564bc..e98a0e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ This is the changelog for [Authress SDK](readme.md). ## 1.5 ## * Fix `DateTimeOffset` type assignments, properties that were incorrectly defined as `DateTime` are now correctly `DateTimeOffsets`. +* Add in `VerifyToken()` method to `AuthressClient`.. ## 1.4 ## * Support exponential back-off retries on unexpected failures. diff --git a/src/Authress.SDK/Client/AuthressClient.cs b/src/Authress.SDK/Client/AuthressClient.cs index 6b54a78..9f38951 100644 --- a/src/Authress.SDK/Client/AuthressClient.cs +++ b/src/Authress.SDK/Client/AuthressClient.cs @@ -40,7 +40,7 @@ public AuthressClient(ITokenProvider tokenProvider, HttpClientSettings settings, throw new ArgumentNullException("Missing required parameter HttpClientSettings"); } authressHttpClientProvider = new HttpClientProvider( - new AuthressSettings { ApiBasePath = settings?.ApiBasePath, RequestTimeout = settings?.RequestTimeout }, + new AuthressSettings { ApiBasePath = settings.ApiBasePath, RequestTimeout = settings.RequestTimeout }, tokenProvider, customHttpClientHandlerFactory); tokenVerifier = new TokenVerifier(settings.ApiBasePath, authressHttpClientProvider); } diff --git a/src/Authress.SDK/Client/AuthressClientTokenProvider.cs b/src/Authress.SDK/Client/AuthressClientTokenProvider.cs index 4798ece..1b00365 100644 --- a/src/Authress.SDK/Client/AuthressClientTokenProvider.cs +++ b/src/Authress.SDK/Client/AuthressClientTokenProvider.cs @@ -144,9 +144,9 @@ public Task GetBearerToken(string authressCustomDomainFallback = null) { { "iss", GetIssuer(authressCustomDomainFallback) }, { "sub", accessKey.ClientId }, - { "exp", expiryDate.Subtract(new DateTime(1970,1,1,0,0,0, DateTimeKind.Utc)).TotalSeconds }, - { "iat", now.Subtract(new DateTime(1970,1,1,0,0,0, DateTimeKind.Utc)).TotalSeconds }, - { "nbf", now.Subtract(TimeSpan.FromMinutes(10)).Subtract(new DateTime(1970,1,1,0,0,0, DateTimeKind.Utc)).TotalSeconds }, + { "exp", (int)Math.Floor(expiryDate.Subtract(new DateTime(1970,1,1,0,0,0, DateTimeKind.Utc)).TotalSeconds) }, + { "iat", (int)Math.Floor(now.Subtract(new DateTime(1970,1,1,0,0,0, DateTimeKind.Utc)).TotalSeconds) }, + { "nbf", (int)Math.Floor(now.Subtract(TimeSpan.FromMinutes(10)).Subtract(new DateTime(1970,1,1,0,0,0, DateTimeKind.Utc)).TotalSeconds) }, { "aud", accessKey.Audience }, { "scopes", "openid" } }; diff --git a/src/Authress.SDK/Client/HttpClientProvider.cs b/src/Authress.SDK/Client/HttpClientProvider.cs index 4eeb92c..0ea9b66 100644 --- a/src/Authress.SDK/Client/HttpClientProvider.cs +++ b/src/Authress.SDK/Client/HttpClientProvider.cs @@ -68,7 +68,7 @@ internal class HttpClientProvider public HttpClientProvider(AuthressSettings settings, ITokenProvider tokenProvider, IHttpClientHandlerFactory customHttpClientHandlerFactory = null) { - this.settings = settings; + this.settings = settings ?? new AuthressSettings(); this.tokenProvider = tokenProvider; this.customHttpClientHandlerFactory = customHttpClientHandlerFactory; } diff --git a/src/Authress.SDK/Client/JWT/JwtPayload.cs b/src/Authress.SDK/Client/JWT/JwtPayload.cs index 96883c7..e9de10e 100644 --- a/src/Authress.SDK/Client/JWT/JwtPayload.cs +++ b/src/Authress.SDK/Client/JWT/JwtPayload.cs @@ -1,4 +1,7 @@ +using System; using System.Runtime.Serialization; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; namespace Authress.SDK.Client.JWT { @@ -11,6 +14,10 @@ internal class JwtPayload [DataMember(Name = "iss")] internal string Issuer { get; set; } + + [DataMember(Name = "exp")] + [JsonConverter(typeof(UnixDateTimeConverter))] + internal DateTimeOffset Expires { get; set; } } [DataContract] diff --git a/src/Authress.SDK/Client/TokenVerifier.cs b/src/Authress.SDK/Client/TokenVerifier.cs index 65325a4..66af9ad 100644 --- a/src/Authress.SDK/Client/TokenVerifier.cs +++ b/src/Authress.SDK/Client/TokenVerifier.cs @@ -21,6 +21,10 @@ using NSec.Cryptography; using System.Text; using Org.BouncyCastle.Utilities.Encoders; +using Org.BouncyCastle.OpenSsl; +using System.IO; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.Crypto.Parameters; namespace Authress.SDK { @@ -48,7 +52,7 @@ internal TokenVerificationException(String message) : base(message) {} internal enum Alg { [EnumMember(Value = "EdDSA")] EdDSA = 1, [EnumMember(Value = "RS256")] RS256 = 2, - [EnumMember(Value = "RS512")] RSA512 = 3 + [EnumMember(Value = "RS512")] RS512 = 3 } [DataContract] @@ -142,6 +146,14 @@ public async Task VerifyToken(string jwtToken) { throw new TokenVerificationException("Unauthorized: No Issuer found"); } + if (unverifiedJwtPayload.Expires < DateTime.UtcNow) { + throw new TokenVerificationException("Unauthorized: Token is expired"); + } + + if (string.IsNullOrEmpty(authressCustomDomain)) { + throw new ArgumentNullException("The authress custom domain must be specified in the AuthressSettings."); + } + var completeIssuerUrl = new Uri(Sanitizers.SanitizeUrl(authressCustomDomain)); try { if (new Uri(unverifiedJwtPayload.Issuer).GetLeftPart(UriPartial.Authority) != completeIssuerUrl.GetLeftPart(UriPartial.Authority)) { @@ -159,10 +171,18 @@ public async Task VerifyToken(string jwtToken) { var key = await getPublicKey(unverifiedJwtPayload.Issuer, jwtHeader.KeyId); + var verifiedUserIdentity = VerifySignature(jwtToken, key); + return verifiedUserIdentity; + } + + private VerifiedUserIdentity VerifySignature(string jwtToken, Jwk key) { + + var unverifiedJwtPayload = JsonConvert.DeserializeObject(Base64UrlEncoder.Decode(jwtToken.Split('.')[1])); + if (key.Alg == Alg.EdDSA) { var ed25519alg = SignatureAlgorithm.Ed25519; - var data = Encoding.UTF8.GetBytes($"{jwtHeader}.{unverifiedJwtPayload}"); + var data = Encoding.UTF8.GetBytes($"{jwtToken.Split('.')[0]}.{jwtToken.Split('.')[1]}"); var keyAsString = key.x.Replace('_', '/').Replace('-', '+'); switch(keyAsString.Length % 4) { @@ -170,8 +190,14 @@ public async Task VerifyToken(string jwtToken) { case 3: keyAsString += "="; break; } + var jwtTokenSignature = jwtToken.Split('.')[2].Replace('_', '/').Replace('-', '+'); + switch(jwtTokenSignature.Length % 4) { + case 2: jwtTokenSignature += "=="; break; + case 3: jwtTokenSignature += "="; break; + } + var edDsaPublicKey = NSec.Cryptography.PublicKey.Import(ed25519alg, Convert.FromBase64String(keyAsString), KeyBlobFormat.PkixPublicKey); - var signatureData = Encoding.UTF8.GetBytes(Base64UrlEncoder.Decode(jwtToken.Split('.')[2])); + var signatureData = Convert.FromBase64String(jwtTokenSignature); if (!SignatureAlgorithm.Ed25519.Verify(edDsaPublicKey, data, signatureData)) { throw new TokenVerificationException($"Unauthorized: Token Signature is not valid."); } @@ -182,12 +208,11 @@ public async Task VerifyToken(string jwtToken) { } // ELSE assume RS256 or RS512 - var rsa = new RSACryptoServiceProvider(); rsa.ImportParameters(new RSAParameters() { - Modulus = Encoding.UTF8.GetBytes(Base64UrlEncoder.Decode(key.n)), - Exponent = Encoding.UTF8.GetBytes(Base64UrlEncoder.Decode(key.e)) + Modulus = Convert.FromBase64String(key.n), + Exponent = Convert.FromBase64String(key.e) }); var tokenHandler = new JwtSecurityTokenHandler(); diff --git a/tests/Authress.SDK/Client/Tokenverifier/VerifyTokenTests.cs b/tests/Authress.SDK/Client/Tokenverifier/VerifyTokenTests.cs new file mode 100644 index 0000000..7884dec --- /dev/null +++ b/tests/Authress.SDK/Client/Tokenverifier/VerifyTokenTests.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Authress.SDK.Client; +using Authress.SDK.DTO; +using FluentAssertions; +using Moq; +using Moq.Protected; +using Newtonsoft.Json; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.OpenSsl; +using Org.BouncyCastle.Security; +using Xunit; + +namespace Authress.SDK.UnitTests.TokenVerifier +{ + internal class UnitTestIntentionalException : Exception {} + + public class VerifyTokenTests + { + + private static string authressCustomDomain = "https://unit-test-customdomain.authress.io"; + private static (string, string) eddsaKeys = ("MC4CAQAwBQYDK2VwBCIEIHWOlqpfN1YdPSAvAZlSxOyZs0v0jnOj3flvG4ezJ8/R", "MCowBQYDK2VwAyEAP1ghjuexanmp5hYgSYRvbFJirquynaCyolH3vHb9JCE="); + + [Fact] + public async Task ValidateEddsaToken() { + var testUserId = Guid.NewGuid().ToString(); + var testKeyId = Guid.NewGuid().ToString(); + var authressClientTokenProvider = new AuthressClientTokenProvider($"{testUserId}.{testKeyId}.account.{eddsaKeys.Item1}", authressCustomDomain); + // setup + var edDsaJwkResponse = new JwkResponse { Keys = new List { new Jwk { Alg = Alg.EdDSA, kid = testKeyId, x = eddsaKeys.Item2 } } }; + var jwtToken = await authressClientTokenProvider.GetBearerToken(); + + var mockHttpClient = new Mock(MockBehavior.Strict); + mockHttpClient.Protected().SetupSequence>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(() => new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = edDsaJwkResponse.ToHttpContent() }); + + var mockFactory = new Mock(MockBehavior.Strict); + mockFactory.Setup(factory => factory.Create()).Returns(mockHttpClient.Object); + + var mockHttpClientProvider = new HttpClientProvider(null, null, mockFactory.Object); + var tokenVerifier = new SDK.TokenVerifier(authressCustomDomain, mockHttpClientProvider); + + var result = await tokenVerifier.VerifyToken(jwtToken); + result.Should().BeEquivalentTo(new VerifiedUserIdentity { UserId = testUserId }); + + mockFactory.Verify(mockFactory => mockFactory.Create(), Times.Once()); + mockHttpClient.VerifyAll(); + } + + private static (string, string) rsa512Keys = ("-----BEGIN PRIVATE KEY-----MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDClKx0rcN5hgtLrCrUtpeErBu8wAQIU0Rc3mnb5Kcg4wn6EGvPhyhCTVVpDtBdFyKXmg3hwhI57yON2jeikHsr3pFm6AbgTs4lHlwFJxEIrQYOvD5wxGeiy5boOTCIWl27OtohJOYiRqHvoVzltcOFgESTvTXID0tIDJMnyADfrUyXD2tavHoqaifhGN9eT9ObsnGo7OaIiSspvgm/Sz8zVBumv8DMDM9FMlOPqeXFFhy1ADEQ4LQh6ZuJ7hs78aVBjq7vQmlWYsWu1yIlC4Qufhhnt5/lAsVTHp2YmEqZHWpqrBaus1kXmhG/YZMvzRLfLkv1vi26z/MJoemA3E5tAgMBAAECgf9i5Tk1/QO/0ZWDZlUwAeOAs7Mfe9V5lEz2Q6BnKIQO/puwmigC70v1fh58ZBDrnH9PsQ5RhykNyPegYU581yxOvMqSt0+Bjxe3LqWKylYeAUcYtiupGUEe1Al8INrVidKCokm7VJcummYzsJt1JPKd+SThrCwMcP9X9oCOmm/XPq+o5HjzovxN9MxJLhsZH6oBqcMD/nBf0aLPYPMRRM/6CxvWHQdMYGHhUdfUtRHR977ZwPJEOTe0p8Z+zNM/yZ3oSaLyu8928kpeFWQWkNEOj71DLpFkOtPRMeXIxlaL7wIEAas2oeBTmdkpJuRQG9sjsBLJVaBnoCzBcXqY6vECgYEA/4njaT2SfBs4URqVFyBPTc9a/gCipOegzLFP1+T+Ng5qbmlGxpTmqdqFpPa/N3BmzrLs/PHJcDc5hAD8B6ChsSPozRLtathFr7YT/SJi2GLBikRj/+UwsO5pWNLUkKUqA80G1G2rRaj1PoXhOm9sB4E9bm/pghFUYCwUrFEqhHECgYEAwu6cNZNnBO1enyoBsT9LWnr8MeLP9Seqr2vWGKY5awliEDW/LebvryEfEumTMDjMPa+1nBdsWMJ4O+fdGaLIGet8TKBD8Lt+00N2ZRvJAmS6DLfZkhUOawzi51tQYuJhHdQJavXOzAMOjDytAQr23m5D+ffb/wi5Qm5VdrNod70CgYEA20LMT1vWmgidHPIrJQnDIieekr2m0MoyrhAiS0QgX++koRJR+UiAVxO6gp552i7m98qNEEjCqDeqcTqLBlxtANqoAXaRIpFp0efwZM4hdDvghyxBhat5SQd4ew+D9ozRbSt6BcIIKKBdtgUYCZTbY+Ef/eemu8T02gRYxLZsPfECgYAZHNpdAJdmCBqHkMbVCd1wU6XH23uFDs4reU4EsO12v6e1hOcTR8wbGL5DFnpS3Q5a6BcSe+YGbU9GEHHoipMS28aQrJj0G4OUPf2zuuejekyJtOm/qxzHZ8qXmaj6hEWUrStlbzNsDvzBzlNPbhUtxLDXNDpQWdRcHZC/EQ/lVQKBgQD8aQs5tYZ0Zt6zLAw8J43M4vGsTTEL9YziSRfVdRYMr1yAPMoVQxujjY4+hYm9//BK2Kfq2yxTYpN+ShoJJWY4GGrJt5ZGsbIgJpdBP7UzMbZXg7CvwsKtPnKk7iuNPUxF7B856zOwHwbsPYNAJB3snerTLIy2/00oOXIVGYAJEw==-----END PRIVATE KEY-----", "-----BEGIN PUBLIC KEY-----MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwpSsdK3DeYYLS6wq1LaXhKwbvMAECFNEXN5p2+SnIOMJ+hBrz4coQk1VaQ7QXRcil5oN4cISOe8jjdo3opB7K96RZugG4E7OJR5cBScRCK0GDrw+cMRnosuW6DkwiFpduzraISTmIkah76Fc5bXDhYBEk701yA9LSAyTJ8gA361Mlw9rWrx6Kmon4RjfXk/Tm7JxqOzmiIkrKb4Jv0s/M1Qbpr/AzAzPRTJTj6nlxRYctQAxEOC0Iembie4bO/GlQY6u70JpVmLFrtciJQuELn4YZ7ef5QLFUx6dmJhKmR1qaqwWrrNZF5oRv2GTL80S3y5L9b4tus/zCaHpgNxObQIDAQAB-----END PUBLIC KEY-----"); + + [Fact] + public async Task ValidateRsaToken() { + var testUserId = Guid.NewGuid().ToString(); + var testKeyId = Guid.NewGuid().ToString(); + + + var accessKeyAsString = System.Convert.ToBase64String(ASCIIEncoding.ASCII.GetBytes(JsonConvert.SerializeObject(new AccessKey { + Algorithm = "RS256", + PrivateKey = rsa512Keys.Item1, + ClientId = testUserId, + KeyId = testKeyId, + Audience = "account" + }))); + var authressClientTokenProvider = new AuthressClientTokenProvider(accessKeyAsString, authressCustomDomain); + // setup + + var csp = new RSACryptoServiceProvider(); + csp.ImportParameters(DotNetUtilities.ToRSAParameters((RsaKeyParameters)new PemReader(new StringReader(rsa512Keys.Item2)).ReadObject())); + var rsaParameters = csp.ExportParameters(false); + + var rsaJwkResponse = new JwkResponse { Keys = new List { new Jwk { + Alg = Alg.RS256, kid = testKeyId, n = Convert.ToBase64String(rsaParameters.Modulus), e = Convert.ToBase64String(rsaParameters.Exponent) + } } }; + var jwtToken = await authressClientTokenProvider.GetBearerToken(); + + var mockHttpClient = new Mock(MockBehavior.Strict); + mockHttpClient.Protected().SetupSequence>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(() => new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = rsaJwkResponse.ToHttpContent() }); + + var mockFactory = new Mock(MockBehavior.Strict); + mockFactory.Setup(factory => factory.Create()).Returns(mockHttpClient.Object); + + var mockHttpClientProvider = new HttpClientProvider(null, null, mockFactory.Object); + var tokenVerifier = new SDK.TokenVerifier(authressCustomDomain, mockHttpClientProvider); + + var result = await tokenVerifier.VerifyToken(jwtToken); + result.Should().BeEquivalentTo(new VerifiedUserIdentity { UserId = testUserId }); + + mockFactory.Verify(mockFactory => mockFactory.Create(), Times.Once()); + mockHttpClient.VerifyAll(); + } + } +}