diff --git a/OrderCloud.Catalyst.TestApi/Controllers/DemoController.cs b/OrderCloud.Catalyst.TestApi/Controllers/DemoController.cs index 9525adc..ab1d994 100644 --- a/OrderCloud.Catalyst.TestApi/Controllers/DemoController.cs +++ b/OrderCloud.Catalyst.TestApi/Controllers/DemoController.cs @@ -1,12 +1,12 @@ +using Microsoft.AspNetCore.Mvc; +using OrderCloud.SDK; +using Stripe; using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc; -using OrderCloud.SDK; -using OrderCloud.Catalyst; using RequiredAttribute = System.ComponentModel.DataAnnotations.RequiredAttribute; namespace OrderCloud.Catalyst.TestApi @@ -14,12 +14,12 @@ namespace OrderCloud.Catalyst.TestApi [Route("demo")] public class DemoController : CatalystController { - private readonly RequestAuthenticationService _tokenProvider; + private readonly IRequestAuthenticationService _auth; private readonly IOrderCloudClient _oc; - public DemoController(RequestAuthenticationService tokenProvider, IOrderCloudClient oc) + public DemoController(IRequestAuthenticationService auth, IOrderCloudClient oc) { - _tokenProvider = tokenProvider; + _auth = auth; _oc = oc; } @@ -28,7 +28,20 @@ public object Shop() { return "hello shopper!"; } - [HttpGet("admin"), OrderCloudUserAuth(ApiRole.OrderAdmin)] + [HttpGet("simpleuserinfo"), OrderCloudUserInfoAuth] + public object SimpleUserInfo() + { + return "hello userinfo!"; + } + + [HttpGet("customuserinfo"), OrderCloudUserInfoAuth("CustomRole")] + public object CustomUserInfo() + { + return "hello custom userinfo!"; + } + + + [HttpGet("admin"), OrderCloudUserAuth(ApiRole.OrderAdmin)] public object Admin() => "hello admin!"; [HttpGet("either"), OrderCloudUserAuth("Shopper", "OrderAdmin")] @@ -94,21 +107,49 @@ public SimplifiedUser Username() }; } - [HttpGet("username"), OrderCloudUserAuth] + [HttpGet("userinfocontext"), OrderCloudUserInfoAuth] + public SimplifiedUser GetUserInfoContext() + { + return new SimplifiedUser() + { + AvailableRoles = UserInfoContext.Roles.ToList(), + Username = UserInfoContext.Username + }; + } + + [HttpPost("userinfocontext/{token}")] + public async Task SetUserInfoContext(string token) + { + var user = await _auth.VerifyUserInfoTokenAsync(token); + return new SimplifiedUser() + { + AvailableRoles = user.Roles.ToList(), + Username = user.Username + }; + } + + [HttpGet("username"), OrderCloudUserAuth] public string GetUserName() { Thread.Sleep(1000); // pause for 1 sec return UserContext.Username; } - [HttpPost("usercontext/{token}")] + [HttpGet("userinfousername"), OrderCloudUserInfoAuth] + public string GetUserInfoUserName() + { + Thread.Sleep(1000); // pause for 1 sec + return UserInfoContext.Username; + } + + [HttpPost("usercontext/{token}")] public async Task SetUserContext(string token) { var opts = new OrderCloudUserAuthOptions() { AnyClientIDCanAccess = true }; - var user = await _tokenProvider.VerifyTokenAsync(token, opts); + var user = await _auth.VerifyTokenAsync(token, opts); return new SimplifiedUser() { AvailableRoles = user.Roles.ToList(), Username = user.Username, diff --git a/OrderCloud.Catalyst.TestApi/Controllers/WebhookController.cs b/OrderCloud.Catalyst.TestApi/Controllers/WebhookController.cs index c0fb5fb..9aeb31d 100644 --- a/OrderCloud.Catalyst.TestApi/Controllers/WebhookController.cs +++ b/OrderCloud.Catalyst.TestApi/Controllers/WebhookController.cs @@ -1,6 +1,4 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using OrderCloud.Catalyst; +using Microsoft.AspNetCore.Mvc; using OrderCloud.SDK; using System.Threading.Tasks; @@ -8,10 +6,10 @@ namespace OrderCloud.Catalyst.TestApi { public class WebhookController : CatalystController { - private RequestAuthenticationService _service; + private IRequestAuthenticationService _service; private TestSettings _settings; - public WebhookController(RequestAuthenticationService service, TestSettings settings) + public WebhookController(IRequestAuthenticationService service, TestSettings settings) { _service = service; _settings = settings; diff --git a/OrderCloud.Catalyst.TestApi/Startup.cs b/OrderCloud.Catalyst.TestApi/Startup.cs index cd10677..479b810 100644 --- a/OrderCloud.Catalyst.TestApi/Startup.cs +++ b/OrderCloud.Catalyst.TestApi/Startup.cs @@ -1,13 +1,15 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; -using OrderCloud.SDK; -using NSubstitute; +using Microsoft.Extensions.Hosting; using Microsoft.OpenApi.Models; using Newtonsoft.Json.Converters; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Hosting; -using Microsoft.AspNetCore.Http; +using NSubstitute; +using OrderCloud.SDK; +using OrderCloud.Catalyst; +using System.Threading.Tasks; namespace OrderCloud.Catalyst.TestApi { @@ -45,6 +47,7 @@ public virtual void ConfigureServices(IServiceCollection services) builder => { builder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader(); })); services .AddOrderCloudUserAuth(opts => opts.AddValidClientIDs(UnitTestClientID)) + .AddOrderCloudUserInfoAuth() .AddOrderCloudWebhookAuth(opts => opts.HashKey = _settings.OrderCloudSettings.WebhookHashKey) .AddSingleton() // Replace LazyCacheService with RedisService if you have multiple server instances. .AddSingleton(new OrderCloudClient(new OrderCloudClientConfig() @@ -106,7 +109,16 @@ public override void ConfigureServices(IServiceCollection services) AuthUrl = "mockdomain.com", }); oc.Me.GetAsync(Arg.Any()).Returns(new MeUser { Username = "joe", Active = true, AvailableRoles = new[] { "Shopper" } }); - services.AddSingleton(oc); + + + oc + .GetPublicKeyAsync(Arg.Is(k => k == TestRsaKeyProvider.AllowedKid)) + .Returns(Task.FromResult(TestRsaKeyProvider.ToOrderCloudPublicKey(TestRsaKeyProvider.AllowedRsa))); + oc + .GetPublicKeyAsync(Arg.Is(k => k == TestRsaKeyProvider.DeniedKid)) + .Returns(Task.FromResult(TestRsaKeyProvider.ToOrderCloudPublicKey(TestRsaKeyProvider.DeniedRsa))); + + services.AddSingleton(oc); } public override void Configure(IApplicationBuilder app, IWebHostEnvironment env) diff --git a/OrderCloud.Catalyst.Tests/ApiIntegrationTests/UserInfoAuthTests.cs b/OrderCloud.Catalyst.Tests/ApiIntegrationTests/UserInfoAuthTests.cs new file mode 100644 index 0000000..beb48e8 --- /dev/null +++ b/OrderCloud.Catalyst.Tests/ApiIntegrationTests/UserInfoAuthTests.cs @@ -0,0 +1,213 @@ +using AutoFixture; +using AutoFixture.NUnit3; +using FluentAssertions; +using Flurl.Http; +using NSubstitute; +using NUnit.Framework; +using OrderCloud.Catalyst; +using OrderCloud.Catalyst.TestApi; +using OrderCloud.SDK; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading.Tasks; + +namespace OrderCloud.Catalyst.Tests +{ + [TestFixture] + public class UserInfoAuthTests + { + + [Test] + public async Task should_deny_access_without_oc_token() + { + var resp = await TestFramework.Client + .Request("demo/simpleuserinfo") + .GetAsync(); + + await resp.ShouldHaveFirstApiError("InvalidToken", 401, "Access token is invalid or expired."); + } + + [Test] + public async Task can_auth_with_oc_token() + { + var token = FakeUserInfoToken.Create(); + var result = await TestFramework.Client + .WithOAuthBearerToken(token) + .Request("demo/simpleuserinfo") + .GetStringAsync(); + + result.Should().Be("\"hello userinfo!\""); + } + + [Test] + public async Task should_succeed_with_custom_role() + { + var token = FakeUserInfoToken.Create(new List { "CustomRole" }); + var request = TestFramework.Client + .WithOAuthBearerToken(token) + .Request("demo/customuserinfo"); + + var result = await request.GetStringAsync(); + + result.Should().Be("\"hello custom userinfo!\""); + } + + [Test] + public async Task should_succeed_with_with_full_access() + { + var token = FakeUserInfoToken.Create(new List { "FullAccess" }); + var request = TestFramework.Client + .WithOAuthBearerToken(token) + .Request("demo/customuserinfo"); + + var result = await request.GetStringAsync(); + + result.Should().Be("\"hello custom userinfo!\""); + } + + [Test] + public async Task should_error_without_custom_role() + { + var token = FakeUserInfoToken.Create(); + var result = await TestFramework.Client + .WithOAuthBearerToken(token) + .Request("demo/customuserinfo") + .GetAsync(); + + Assert.AreEqual(403, result.StatusCode); + } + + [Test] + public async Task can_get_user_info_context_from_auth() + { + var fixture = new Fixture(); + var username = fixture.Create(); + var token = FakeUserInfoToken.Create(new List { "Shopper" }, username: username); + + var result = await TestFramework.Client + .WithOAuthBearerToken(token) + .Request("demo/userinfocontext") + .GetJsonAsync(); + + Assert.AreEqual(username, result.Username); + Assert.AreEqual("Shopper", result.AvailableRoles[0]); + } + + [Test] + public async Task can_get_user_context_from_setting_it() + { + var fixture = new Fixture(); + var username = fixture.Create(); + var token = FakeUserInfoToken.Create(new List { "Shopper" }, username: username); + + var result = await TestFramework.Client + .Request($"demo/userinfocontext/{token}") + .PostAsync() + .ReceiveJson(); + + Assert.AreEqual(username, result.Username); + Assert.AreEqual("Shopper", result.AvailableRoles[0]); + } + + [Test] + public async Task should_succeed_if_now_is_between_expiry_and_nvb() + { + var token = FakeUserInfoToken.Create( + roles: new List { "Shopper" }, + expiresUTC: DateTime.UtcNow + TimeSpan.FromHours(1), + notValidBeforeUTC: DateTime.UtcNow - TimeSpan.FromHours(1) + ); + + var resp = await TestFramework.Client + .WithOAuthBearerToken(token) + .Request("demo/simpleuserinfo") + .GetStringAsync(); + + resp.Should().Be("\"hello userinfo!\""); + } + + [Test] + public async Task should_deny_access_if_nvb_is_wrong() + { + var fixture = new Fixture(); + + var token = FakeUserInfoToken.Create( + roles: new List { "Shopper" }, + expiresUTC: DateTime.UtcNow + TimeSpan.FromHours(2), + notValidBeforeUTC: DateTime.UtcNow + TimeSpan.FromHours(1) + ); + + var resp = await TestFramework.Client + .WithOAuthBearerToken(token) + .Request("demo/simpleuserinfo") + .GetAsync(); + + await resp.ShouldHaveFirstApiError("InvalidToken", 401, "Access token is invalid or expired."); + } + + [Test] + public async Task should_deny_access_if_past_expiry() + { + var fixture = new Fixture(); + + var token = FakeUserInfoToken.Create( + roles: new List { "Shopper" }, + expiresUTC: DateTime.UtcNow, + notValidBeforeUTC: DateTime.UtcNow - TimeSpan.FromHours(1) + ); + + var resp = await TestFramework.Client + .WithOAuthBearerToken(token) + .Request("demo/simpleuserinfo") + .GetAsync(); + + await resp.ShouldHaveFirstApiError("InvalidToken", 401, "Access token is invalid or expired."); + } + + [Test] + public async Task two_requests_with_the_same_kid_should_verify_both_tokens() + { + var fixture = new Fixture(); + var keyID = fixture.Create(); + + var token1 = FakeUserInfoToken.Create(new List { "Shopper" }, keyID: keyID); + var token2 = token1 + "makethisinvalid"; + + var response1 = await TestFramework.Client.WithOAuthBearerToken(token1).Request("demo/simpleuserinfo").GetAsync(); + var response2 = await TestFramework.Client.WithOAuthBearerToken(token2).Request("demo/simpleuserinfo").GetAsync(); + + await response2.ShouldHaveFirstApiError("InvalidToken", 401, "Access token is invalid or expired."); + } + + + + [Test] + public async Task user_auth_provider_handles_mulitple_concurrent_requests() + { + var fixture = new Fixture(); + var requestCount = 10; + var usernames = new List(); + var requests = new List>(); + + foreach (var i in Enumerable.Range(0, requestCount)) + { + var username = fixture.Create(); + usernames.Add(username); + var token = FakeUserInfoToken.Create(username: username); + var request = TestFramework.Client.WithOAuthBearerToken(token).Request("demo/userinfousername").GetStringAsync(); + requests.Add(request); + } + + var results = await Task.WhenAll(requests); + + foreach (var i in Enumerable.Range(0, requestCount)) + { + Assert.AreEqual("\"" + usernames[i] + "\"", results[i]); + } + } + } +} diff --git a/OrderCloud.Catalyst/Api/CatalystController.cs b/OrderCloud.Catalyst/Api/CatalystController.cs index afd45c0..e0e68d5 100644 --- a/OrderCloud.Catalyst/Api/CatalystController.cs +++ b/OrderCloud.Catalyst/Api/CatalystController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using System.Linq; +using System.Security.Claims; namespace OrderCloud.Catalyst { @@ -14,14 +15,25 @@ public class CatalystController : Controller /// public DecodedToken UserContext { get; private set; } + /// + /// Will be null unless [OrderCloudUserInfoAuth] is added to the route. + /// + public DecodedUserInfoToken UserInfoContext { get; private set; } + public CatalystController() {} public override void OnActionExecuting(ActionExecutingContext context) { - var token = User.Claims.FirstOrDefault(claim => claim.Type == "AccessToken")?.Value; - if (token != null) + var accessToken = User.Claims.FirstOrDefault(claim => claim.Type == "AccessToken")?.Value; + if (accessToken != null) + { + UserContext = new DecodedToken(accessToken); + } + + var userInfoToken = User.Claims.FirstOrDefault(claim => claim.Type == "UserInfoToken")?.Value; + if (userInfoToken != null) { - UserContext = new DecodedToken(token); + UserInfoContext = new DecodedUserInfoToken(userInfoToken); } base.OnActionExecuting(context); } diff --git a/OrderCloud.Catalyst/Auth/RequestAuthenticationService.cs b/OrderCloud.Catalyst/Auth/RequestAuthenticationService.cs new file mode 100644 index 0000000..051595e --- /dev/null +++ b/OrderCloud.Catalyst/Auth/RequestAuthenticationService.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using OrderCloud.SDK; +using System.IO; +using System.Text; + +namespace OrderCloud.Catalyst +{ + + /// + /// Injectable service to aid with getting, decoding, and verifying OrderCloud auth tokens on an HttpRequest. + /// + public interface IRequestAuthenticationService + { + IOrderCloudClient BuildClient(); + DecodedToken GetDecodedToken(); + DecodedToken GetDecodedToken(HttpRequest request); + DecodedUserInfoToken GetDecodedUserInfoToken(); + DecodedUserInfoToken GetDecodedUserInfoToken(HttpRequest request); + string GetToken(); + string GetToken(HttpRequest request); + Task GetUserAsync(); + Task GetUserAsync() where T : MeUser; + string GetWebhookHash(); + string GetWebhookHash(HttpRequest request); + Task VerifyTokenAsync(HttpRequest request, OrderCloudUserAuthOptions options, IEnumerable requiredRoles = null, IEnumerable allowedUserTypes = null); + Task VerifyTokenAsync(OrderCloudUserAuthOptions options, IEnumerable requiredRoles = null, IEnumerable allowedUserTypes = null); + Task VerifyTokenAsync(string token, OrderCloudUserAuthOptions options, IEnumerable requiredRoles = null, IEnumerable allowedUserTypes = null); + Task VerifyUserInfoTokenAsync(IEnumerable requiredRoles = null); + Task VerifyUserInfoTokenAsync(HttpRequest request, IEnumerable requiredRoles = null); + Task VerifyUserInfoTokenAsync(string token, IEnumerable requiredRoles = null); + Task VerifyWebhookHashAsync(HttpRequest request, OrderCloudWebhookAuthOptions options); + Task VerifyWebhookHashAsync(OrderCloudWebhookAuthOptions options); + Task VerifyWebhookHashAsync(string requestHash, HttpRequest request, OrderCloudWebhookAuthOptions options); + bool VerifyWebhookHashAsync(string requestHash, string requestBody, OrderCloudWebhookAuthOptions options); + } + + public class RequestAuthenticationService : IRequestAuthenticationService + { + private readonly IOrderCloudClient _oc; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ITokenValidator _tokenValidator; + + public RequestAuthenticationService(IOrderCloudClient oc, IHttpContextAccessor httpContextAccessor, ITokenValidator tokenValidator) + { + _oc = oc; + _httpContextAccessor = httpContextAccessor; + _tokenValidator = tokenValidator; + } + + /// + /// Get a raw OrderCloud token from the provided request headers + /// + public string GetToken(HttpRequest request) + { + if (!request.Headers.TryGetValue("Authorization", out var header)) + return null; + + var parts = header.FirstOrDefault()?.Split(new[] { ' ' }, 2); + if (parts?.Length != 2) + return null; + + if (parts[0] != "Bearer") + return null; + + var accessToken = parts[1].Trim(); + Require.That(!string.IsNullOrEmpty(accessToken), new UnAuthorizedException()); + + return accessToken; + } + + /// + /// Get the header "X-oc-hash" of the provided request. Used to verify the request originated from OrderCloud. + /// + public string GetWebhookHash(HttpRequest request) + { + var sentHash = request.Headers?["X-oc-hash"].FirstOrDefault(); + Require.That(!string.IsNullOrEmpty(sentHash), new WebhookUnauthorizedException()); + return sentHash; + } + + /// + /// Get the header "X-oc-hash" of the current HttpContext Request. Used to verify the request originated from OrderCloud. + /// + public string GetWebhookHash() + { + return GetWebhookHash(_httpContextAccessor.HttpContext.Request); + } + + /// + /// Get a raw OrderCloud token from the current HttpContext request headers + /// + public string GetToken() + { + return GetToken(_httpContextAccessor.HttpContext.Request); + } + + /// + /// Get a strongly typed model of the OrderCloud token from the provided request headers + /// + public DecodedToken GetDecodedToken(HttpRequest request) + { + var token = GetToken(request); + return new DecodedToken(token); + } + + /// + /// Get a strongly typed model of the OrderCloud token from the current HttpContext request headers + /// + public DecodedToken GetDecodedToken() + { + return GetDecodedToken(_httpContextAccessor.HttpContext.Request); + } + + /// + /// Get a strongly typed model of the OrderCloud token from the provided request headers + /// + public DecodedUserInfoToken GetDecodedUserInfoToken(HttpRequest request) + { + var token = GetToken(request); + return new DecodedUserInfoToken(token); + } + + /// + /// Get a strongly typed model of the OrderCloud token from the current HttpContext request headers + /// + public DecodedUserInfoToken GetDecodedUserInfoToken() + { + return GetDecodedUserInfoToken(_httpContextAccessor.HttpContext.Request); + } + + /// + /// Verify the provided OrderCloud access token. Throws 401 if invalid or 403 if insufficient roles. + /// + public async Task VerifyTokenAsync(string token, OrderCloudUserAuthOptions options, IEnumerable requiredRoles = null, IEnumerable allowedUserTypes = null) + { + return await _tokenValidator.ValidateAccessTokenAsync(token, options, requiredRoles, allowedUserTypes); + } + + /// + /// Verify the provided HttpRequest's OrderCloud access Token. Throws 401 if invalid or 403 if insufficient roles. + /// + public async Task VerifyTokenAsync(HttpRequest request, OrderCloudUserAuthOptions options, IEnumerable requiredRoles = null, IEnumerable allowedUserTypes = null) + { + var token = GetToken(request); + return await VerifyTokenAsync(token, options, requiredRoles, allowedUserTypes); + } + + + /// + /// Verify the current HttpContext request's OrderCloud access token. Throws 401 if invalid or 403 if insufficient roles. + /// + public async Task VerifyTokenAsync(OrderCloudUserAuthOptions options, IEnumerable requiredRoles = null, IEnumerable allowedUserTypes = null) + { + return await VerifyTokenAsync(_httpContextAccessor.HttpContext.Request, options, requiredRoles, allowedUserTypes); + } + + /// + /// Verify the provided OrderCloud userinfo token. Throws 401 if invalid or 403 if insufficient roles. + /// + public async Task VerifyUserInfoTokenAsync(string token, IEnumerable requiredRoles = null) + { + return await _tokenValidator.ValidateUserInfoTokenAsync(token, requiredRoles); + } + + /// + /// Verify the provided HttpRequest's OrderCloud userinfo Token. Throws 401 if invalid or 403 if insufficient roles. + /// + public async Task VerifyUserInfoTokenAsync(HttpRequest request, IEnumerable requiredRoles = null) + { + var token = GetToken(request); + return await VerifyUserInfoTokenAsync(token, requiredRoles); + } + + + /// + /// Verify the current HttpContext request's OrderCloud userinfo token. Throws 401 if invalid or 403 if insufficient roles. + /// + public async Task VerifyUserInfoTokenAsync(IEnumerable requiredRoles = null) + { + return await VerifyUserInfoTokenAsync(_httpContextAccessor.HttpContext.Request, requiredRoles); + } + + /// + /// Verify the provided webhook hash. Proves the request originated from OrderCloud. + /// + public bool VerifyWebhookHashAsync(string requestHash, string requestBody, OrderCloudWebhookAuthOptions options) + { + Require.That(!string.IsNullOrEmpty(options.HashKey), + new InvalidOperationException("OrderCloudWebhookAuthOptions.HashKey was not configured.")); + + Require.That(!string.IsNullOrEmpty(requestBody), new WebhookUnauthorizedException()); + Require.That(!string.IsNullOrEmpty(requestHash), new WebhookUnauthorizedException()); + + var bodyBytes = Encoding.UTF8.GetBytes(requestBody); + var keyBytes = Encoding.UTF8.GetBytes(options.HashKey); + var hash = new HMACSHA256(keyBytes).ComputeHash(bodyBytes); + var computed = Convert.ToBase64String(hash); + + Require.That(requestHash == computed, new WebhookUnauthorizedException()); + return true; + } + + + /// + /// Verify the provided webhook hash. Proves the request originated from OrderCloud. + /// + public async Task VerifyWebhookHashAsync(string requestHash, HttpRequest request, OrderCloudWebhookAuthOptions options) + { + var requestBody = await GetHttpRequestBody(request); + return VerifyWebhookHashAsync(requestHash, requestBody, options); + } + + /// + /// Verify the provided HttpContext request's webhook hash. Proves the request came from OrderCloud. + /// + public async Task VerifyWebhookHashAsync(HttpRequest request, OrderCloudWebhookAuthOptions options) + { + var requestHash = GetWebhookHash(request); + return await VerifyWebhookHashAsync(requestHash, request, options); + } + + /// + /// Verify the current HttpContext request's webhook hash. Proves the request came from OrderCloud. + /// + public async Task VerifyWebhookHashAsync(OrderCloudWebhookAuthOptions options) + { + return await VerifyWebhookHashAsync(_httpContextAccessor.HttpContext.Request, options); + + } + + /// + /// Get the full details of the currently authenticated user based on the HttpContext request token + /// + public async Task GetUserAsync() + where T : MeUser + { + var token = GetToken(); + return await _oc.Me.GetAsync(token); + } + + + /// + /// Get the full details of the currently authenticated user based on the HttpContext request token + /// + public async Task GetUserAsync() + { + var token = GetToken(); + return await _oc.Me.GetAsync(token); + } + + /// + /// Get an IOrderCloudClient with token set based on the HttpContext request + /// + public IOrderCloudClient BuildClient() + { + return GetDecodedToken().BuildClient(); + } + + /// + /// This still won't work inside a controller unless there's middleware to run request.EnableBuffering(); + /// See https://stackoverflow.com/questions/59185410/request-body-from-is-empty-in-net-core-3-0 + /// + private async Task GetHttpRequestBody(HttpRequest request) + { + request.EnableBuffering(); + request.Body.Position = 0; + try + { + return await new StreamReader(request.Body).ReadToEndAsync(); + } + finally + { + request.Body.Position = 0; + } + } + } +} diff --git a/OrderCloud.Catalyst/Auth/UserAuth/FakeOrderCloudToken.cs b/OrderCloud.Catalyst/Auth/TestHelpers/FakeOrderCloudToken.cs similarity index 100% rename from OrderCloud.Catalyst/Auth/UserAuth/FakeOrderCloudToken.cs rename to OrderCloud.Catalyst/Auth/TestHelpers/FakeOrderCloudToken.cs diff --git a/OrderCloud.Catalyst/Auth/TestHelpers/FakeUserInfoToken.cs b/OrderCloud.Catalyst/Auth/TestHelpers/FakeUserInfoToken.cs new file mode 100644 index 0000000..080f950 --- /dev/null +++ b/OrderCloud.Catalyst/Auth/TestHelpers/FakeUserInfoToken.cs @@ -0,0 +1,74 @@ + +// FakeUserInfoToken.cs +using Microsoft.IdentityModel.Tokens; +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace OrderCloud.Catalyst +{ + public class FakeUserInfoToken + { + /// + /// Create a fake token for unit testing. (Grants no access to the API). + /// If 'signingCredentials' is supplied, the token is signed with those credentials (e.g., RS256). + /// Otherwise, falls back to HMAC SHA256 with a static symmetric key. + /// + public static string Create( + List roles = null, + DateTime? expiresUTC = null, + DateTime? notValidBeforeUTC = null, + string username = null, + string keyID = TestRsaKeyProvider.AllowedKid, + string authUrl = null, + string apiUrl = null, + string companyID = null, + List groups = null, + string marketplaceID = null, + SigningCredentials signingCredentials = null + ) + { + var creds = signingCredentials ?? TestRsaKeyProvider.AllowedSigningCredentials; + + var header = new JwtHeader(creds); + if (keyID != null) + { + header["kid"] = keyID; + } + + var claims = new List(); + + foreach (var role in roles ?? new List()) + { + claims.Add(new Claim("availableroles", role)); + } + + foreach (var group in groups ?? new List()) + { + claims.Add(new Claim("groups", group)); + } + + AddClaimIfNotNull(claims, "sub", username); + AddClaimIfNotNull(claims, "marketplaceID", marketplaceID); + AddClaimIfNotNull(claims, "companyID", companyID); + + var payload = new JwtPayload( + issuer: authUrl ?? "mockdomain.com", + audience: apiUrl ?? "mockdomain.com", + claims: claims, + expires: expiresUTC ?? DateTime.UtcNow.AddMinutes(30), + notBefore: notValidBeforeUTC ?? DateTime.UtcNow + ); + + var token = new JwtSecurityToken(header, payload); + return new JwtSecurityTokenHandler().WriteToken(token); + } + + private static void AddClaimIfNotNull(List claims, string type, string value) + { + if (value != null) { claims.Add(new Claim(type, value)); } + } + } +} diff --git a/OrderCloud.Catalyst/Auth/TestHelpers/TestRsaKeyProvider.cs b/OrderCloud.Catalyst/Auth/TestHelpers/TestRsaKeyProvider.cs new file mode 100644 index 0000000..1254c51 --- /dev/null +++ b/OrderCloud.Catalyst/Auth/TestHelpers/TestRsaKeyProvider.cs @@ -0,0 +1,48 @@ + +using System.Security.Cryptography; +using Microsoft.IdentityModel.Tokens; +using OrderCloud.SDK; + +namespace OrderCloud.Catalyst +{ + /// + /// Provides two RSA keypairs: + /// - Allowed: used to sign valid tokens and returned for AllowedKid. + /// - Denied: returned for all other kids (won't validate the allowed token). + /// + public static class TestRsaKeyProvider + { + public const string AllowedKid = "kid-allow"; + public const string DeniedKid = "kid-block"; + + private static readonly RSA _allowedRsa = CreateRsa(2048); + private static readonly RSA _deniedRsa = CreateRsa(2048); + + public static RSA AllowedRsa => _allowedRsa; + public static RSA DeniedRsa => _deniedRsa; + + public static SigningCredentials AllowedSigningCredentials => + new SigningCredentials(new RsaSecurityKey(_allowedRsa) { KeyId = AllowedKid }, SecurityAlgorithms.RsaSha256); + + public static (string n, string e) ExportBase64UrlPublicParts(RSA rsa) + { + var p = rsa.ExportParameters(false); + var n = Base64UrlEncoder.Encode(p.Modulus); + var e = Base64UrlEncoder.Encode(p.Exponent); + return (n, e); + } + + public static PublicKey ToOrderCloudPublicKey(RSA rsa) + { + var (n, e) = ExportBase64UrlPublicParts(rsa); + return new PublicKey { n = n, e = e }; + } + + private static RSA CreateRsa(int keySize) + { + var rsa = RSA.Create(); + rsa.KeySize = keySize; + return rsa; + } + } +} diff --git a/OrderCloud.Catalyst/Auth/TokenValidator.cs b/OrderCloud.Catalyst/Auth/TokenValidator.cs new file mode 100644 index 0000000..742be38 --- /dev/null +++ b/OrderCloud.Catalyst/Auth/TokenValidator.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Threading.Tasks; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using OrderCloud.SDK; + +namespace OrderCloud.Catalyst +{ + /// + /// Injectable service to aid with validating OrderCloud tokens + /// + public interface ITokenValidator + { + Task ValidateAccessTokenAsync(string token, OrderCloudUserAuthOptions options, IEnumerable requiredRoles = null, IEnumerable allowedUserTypes = null); + Task ValidateUserInfoTokenAsync(string token, IEnumerable requiredRoles = null); + } + + public class TokenValidator : ITokenValidator + { + private readonly ISimpleCache _cache; + private readonly IOrderCloudClient _oc; + + public TokenValidator(ISimpleCache cache, IOrderCloudClient oc) + { + _cache = cache; + _oc = oc; + } + + /// + /// Validate the provided OrderCloud access token. Throws 401 if invalid or 403 if insufficient roles or wrong user types + /// + public async Task ValidateAccessTokenAsync(string token, OrderCloudUserAuthOptions options, IEnumerable requiredRoles = null, IEnumerable allowedUserTypes = null) + { + Require.That(!string.IsNullOrEmpty(token), new UnAuthorizedException()); + + var decodedToken = new DecodedToken(token); + + Require.That(decodedToken.ClientID != null, new UnAuthorizedException()); + + Require.That(options.AnyClientIDCanAccess || options.ValidClientIDs.Contains(decodedToken.ClientID, StringComparer.InvariantCultureIgnoreCase), new UnAuthorizedException()); + + Require.That(decodedToken.NotValidBeforeUTC < DateTime.UtcNow && decodedToken.ExpiresUTC > DateTime.UtcNow, + new UnAuthorizedException()); + + // we've validated the token as much as we can on this end, go make sure it's ok in OC + bool isValid; + // some valid tokens - e.g. those from the portal - do not have a "kid" + if (decodedToken.KeyID == null) + { + isValid = await ValidateAccessTokenWithMeGet(decodedToken); // also sets meUser field; + } + else + { + isValid = await ValidateTokenWithKeyID(decodedToken.ApiUrl, decodedToken.KeyID, decodedToken.AccessToken); + } + + if (!isValid) + { + Require.That(decodedToken.ApiUrl == _oc?.Config?.ApiUrl, + new WrongEnvironmentException(new WrongEnvironmentError() + { + ExpectedEnvironment = _oc?.Config?.ApiUrl, + TokenIssuerEnvironment = decodedToken.ApiUrl + } + )); + } + + Require.That(isValid, new UnAuthorizedException()); + + Require.That(allowedUserTypes.IsNullOrEmpty() || allowedUserTypes.Contains(decodedToken.CommerceRole), + new InvalidUserTypeException(new InvalidUserTypeError() + { + ThisUserType = decodedToken?.CommerceRole.ToString(), + UserTypesThatCanAccess = allowedUserTypes?.Select(x => x.ToString())?.ToList() + }) + ); + + Require.That(requiredRoles.IsNullOrEmpty() || requiredRoles.Any(role => decodedToken.Roles.Contains(role)), + new InsufficientRolesException(new InsufficientRolesError() + { + SufficientRoles = requiredRoles?.ToList(), + AssignedRoles = decodedToken.Roles.ToList() + }) + ); + + return decodedToken; + } + + /// + /// Validate the provided UserInfo access token. Throws 401 if invalid or 403 if insufficient roles. + /// + public async Task ValidateUserInfoTokenAsync(string token, IEnumerable requiredRoles = null) + { + Require.That(!string.IsNullOrEmpty(token), new UnAuthorizedException()); + + var decodedToken = new DecodedUserInfoToken(token); + + Require.That(decodedToken.NotValidBeforeUTC < DateTime.UtcNow && decodedToken.ExpiresUTC > DateTime.UtcNow, + new UnAuthorizedException()); + + // we've validated the token as much as we can on this end, go make sure it's ok in OC + bool isValid = await ValidateTokenWithKeyID(decodedToken.ApiUrl, decodedToken.KeyID, decodedToken.UserInfoToken); + + if (!isValid) + { + Require.That(decodedToken.ApiUrl == _oc?.Config?.ApiUrl, + new WrongEnvironmentException(new WrongEnvironmentError() + { + ExpectedEnvironment = _oc?.Config?.ApiUrl, + TokenIssuerEnvironment = decodedToken.ApiUrl + } + )); + } + + Require.That(isValid, new UnAuthorizedException()); + + Require.That(requiredRoles.IsNullOrEmpty() || requiredRoles.Any(role => decodedToken.Roles.Contains(role)), + new InsufficientRolesException(new InsufficientRolesError() + { + SufficientRoles = requiredRoles?.ToList(), + AssignedRoles = decodedToken.Roles.ToList() + }) + ); + + return decodedToken; + } + + /// + /// Verifiy the validity of an OrderCloud token, given details about the public key. + /// + private static async Task ValidateTokenWithPublicKey(string token, PublicKey publicKey) + { + if (publicKey == null) + { + return false; + } + var rsa = new RSACryptoServiceProvider(2048); + rsa.ImportParameters(new RSAParameters + { + Modulus = FromBase64Url(publicKey.n), + Exponent = FromBase64Url(publicKey.e) + }); + var rsaSecurityKey = new RsaSecurityKey(rsa); + + var result = await new JsonWebTokenHandler().ValidateTokenAsync(token, new TokenValidationParameters + { + IssuerSigningKey = rsaSecurityKey, + RequireSignedTokens = true, + ValidateIssuerSigningKey = true, + ValidateLifetime = true, + LifetimeValidator = (nbf, exp, _, __) => nbf < DateTime.UtcNow && exp > DateTime.UtcNow, + ValidateIssuer = false, + RequireExpirationTime = true, + ValidateAudience = false + }); + return result.IsValid; + } + + private async Task ValidateAccessTokenWithMeGet(DecodedToken jwt) + { + var cacheKey = jwt.AccessToken; + + return await _cache.GetOrAddAsync(cacheKey, TimeSpan.FromHours(1), async () => + { + try + { + var meUser = await _oc.Me.GetAsync(jwt.AccessToken); + return meUser != null && meUser.Active; + } + catch (OrderCloudException ex) + { + throw ex; + } + catch (Exception ex) + { + await _cache.RemoveAsync(cacheKey); // not their fault, don't make them wait 1 hr + return false; + } + }); + } + + private async Task ValidateTokenWithKeyID(string apiUrl, string keyId, string token) + { + var cacheKey = $"{apiUrl}-{keyId}"; + var publicKey = await _cache.GetOrAddAsync(cacheKey, TimeSpan.FromDays(30), async () => + { + try + { + return await _oc.GetPublicKeyAsync(keyId); + } + catch (OrderCloudException ex) + { + throw ex; + } + catch (Exception ex) + { + await _cache.RemoveAsync(cacheKey); // not their fault, don't make them wait 5 min + return null; // null public key will lead to unauthorized exception; + } + }); + return await ValidateTokenWithPublicKey(token, publicKey); + } + + private static byte[] FromBase64Url(string base64Url) + { + string padded = base64Url.Length % 4 == 0 + ? base64Url : base64Url + "====".Substring(base64Url.Length % 4); + string base64 = padded.Replace("_", "/") + .Replace("-", "+"); + return Convert.FromBase64String(base64); + } + } +} diff --git a/OrderCloud.Catalyst/Auth/UserAuth/OrderCloudUserAuth.cs b/OrderCloud.Catalyst/Auth/UserAuth/OrderCloudUserAuth.cs index 5702302..07b2f2f 100644 --- a/OrderCloud.Catalyst/Auth/UserAuth/OrderCloudUserAuth.cs +++ b/OrderCloud.Catalyst/Auth/UserAuth/OrderCloudUserAuth.cs @@ -45,25 +45,25 @@ public OrderCloudUserAuthAttribute(params string[] roles) public class OrderCloudUserAuthHandler : AuthenticationHandler { - private static RequestAuthenticationService _tokenProvider; + private readonly IRequestAuthenticationService _auth; public OrderCloudUserAuthHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, - RequestAuthenticationService tokenProvider + IRequestAuthenticationService auth ) : base(options, logger, encoder, clock) { - _tokenProvider = tokenProvider; + _auth = auth; } protected override async Task HandleAuthenticateAsync() { try { var requiredRoles = Context.GetRequiredOrderCloudRoles(); var allowedUserTypes = Context.GetAllowedUserTypes(); - var token = await _tokenProvider.VerifyTokenAsync(Request, Options, requiredRoles, allowedUserTypes); + var token = await _auth.VerifyTokenAsync(Request, Options, requiredRoles, allowedUserTypes); var cid = new ClaimsIdentity("OcUser"); cid.AddClaims(token.Roles.Select(r => new Claim(ClaimTypes.Role, r))); cid.AddClaim(new Claim("AccessToken", token.AccessToken)); diff --git a/OrderCloud.Catalyst/Auth/UserAuth/README.md b/OrderCloud.Catalyst/Auth/UserAuth/README.md index 1df1ed3..5c96d8e 100644 --- a/OrderCloud.Catalyst/Auth/UserAuth/README.md +++ b/OrderCloud.Catalyst/Auth/UserAuth/README.md @@ -1,6 +1,6 @@ ## OrderCloud User Authentication -When a user authenticates and acquires an access token from OrderCloud.io, typically in a front-end web or mobile app, that token can be used in your custom endpoints to verify the user's identity and roles. Here are the steps involved: +When a user authenticates and acquires an access token from OrderCloud, typically in a front-end web or mobile app, that token can be used in your custom endpoints to verify the user's identity and roles. Here are the steps involved: #### 1. Register OrderCloud user authentication and register user context in your [`Startup`](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/startup) class. @@ -91,8 +91,18 @@ Proxy the OrderCloud API, adding your own permission logic } ``` -### DecodedToken and RequestAuthenticationService -Outside a request to a Controller you can use the injectable `RequestAuthenticationService` to parse and verify a user's token. +### DecodedToken and IRequestAuthenticationService + +The primary way to enforce authentication in controllers is by using the [OrderCloudUserAuth] attribute on your controller actions. This automatically validates tokens and injects user context for you. + +However, if you prefer to handle authentication inside a service (rather than directly in the controller), IRequestAuthenticationService offers an alternative approach. It allows you to verify tokens and retrieve user details programmatically. + +> **Important:** +IRequestAuthenticationService depends on HttpContext and should only be used within the ASP.NET Core pipeline (e.g., controllers, middleware, or services called from them). +For environments without HttpContext (such as Azure Functions, background services, or console apps), use ITokenValidator instead. ITokenValidator works independently of the current request + + +#### Using IRequestAuthenticationService (ASP.NET Core) ```c# string rawToken = "..."; // Parses the token, but does not verify it. @@ -100,10 +110,14 @@ Outside a request to a Controller you can use the injectable `RequestAuthenticat // Only data on the token is available. user.FirstName and user.xp are not, for example. console.log(user.Username) - // Inject a RequestAuthenticationService to verify. [OrderCloudUserAuth] uses this method under the hood. - DecodedToken verified = await _requestAuthenticationService.VerifyTokenAsync(rawToken); + // Inject a IRequestAuthenticationService to verify. [OrderCloudUserAuth] uses this method under the hood. + var options = new OrderCloudUserAuthOptions + { + ValidClientIDs = new[] { "YOUR_CLIENT_ID" } + }; + DecodedToken verified = await _requestAuthenticationService.VerifyTokenAsync(rawToken, options); - // RequestAuthenticationService can also get a DecodedToken from the current HttpContext. + // IRequestAuthenticationService can also get a DecodedToken from the current HttpContext. // Only use this after calling VerifyTokenAsync, either directly or through [OrderCloudUserAuth]. DecodedToken unverified = await _requestAuthenticationService.GetDecodedToken(); @@ -112,16 +126,16 @@ Outside a request to a Controller you can use the injectable `RequestAuthenticat ``` -Inject the RequestAuthenticationService into a command class. Within a method, an OrderCloud request can be made using that user's token. +Inject the IRequestAuthenticationService into a command class. Within a method, an OrderCloud request can be made using that user's token. ```c# public class OrderSubmitCommand { private readonly IOrderCloudClient _oc; // Injected with Integration Client ID context. FullAccess "super user". - private readonly RequestAuthenticationService _auth; // User token that made the request + private readonly IRequestAuthenticationService _auth; // User token that made the request private readonly ICreditCardCommand _card; // Details of card processing left unopinionated - public OrderSubmitCommand(IOrderCloudClient oc, RequestAuthenticationService auth, ICreditCardCommand card) + public OrderSubmitCommand(IOrderCloudClient oc, IRequestAuthenticationService auth, ICreditCardCommand card) { _oc = oc; _auth = auth; @@ -149,5 +163,35 @@ public class OrderSubmitCommand } ``` - +### Using ITokenValidator (Non-ASP.NET Contexts like Azure Functions) + +ITokenValidator does not depend on HttpContext and works anywhere you have the raw token: + +```c# +public class TokenValidationExample +{ + private readonly ITokenValidator _tokenValidator; + + public TokenValidationExample(ITokenValidator tokenValidator) + { + _tokenValidator = tokenValidator; + } + + public async Task ValidateTokenAsync(string rawToken) + { + var options = new OrderCloudUserAuthOptions + { + ValidClientIDs = new[] { "YOUR_CLIENT_ID" } + }; + + // Validate token and enforce roles/user types if needed + DecodedToken decoded = await _tokenValidator.ValidateAccessTokenAsync( + rawToken, + options + ); + + Console.WriteLine($"Token is valid for user: {decoded.Username}"); + } +} +``` \ No newline at end of file diff --git a/OrderCloud.Catalyst/Auth/UserAuth/RequestAuthenticationService.cs b/OrderCloud.Catalyst/Auth/UserAuth/RequestAuthenticationService.cs deleted file mode 100644 index 562e389..0000000 --- a/OrderCloud.Catalyst/Auth/UserAuth/RequestAuthenticationService.cs +++ /dev/null @@ -1,352 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Security.Cryptography; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.IdentityModel.JsonWebTokens; -using Microsoft.IdentityModel.Tokens; -using OrderCloud.SDK; -using System.IO; -using System.Text; - -namespace OrderCloud.Catalyst -{ - /// - /// Injectable service to aid with getting, decoding, and verifying OrderCloud auth tokens on an HttpRequest. - /// - public class RequestAuthenticationService - { - private readonly ISimpleCache _cache; - private readonly IOrderCloudClient _oc; - private readonly IHttpContextAccessor _httpContextAccessor; - - public RequestAuthenticationService(ISimpleCache cache, IOrderCloudClient oc, IHttpContextAccessor httpContextAccessor) - { - _cache = cache; - _oc = oc; - _httpContextAccessor = httpContextAccessor; - } - - /// - /// Get a raw OrderCloud token from the provided request headers - /// - public static string GetToken(HttpRequest request) - { - if (!request.Headers.TryGetValue("Authorization", out var header)) - return null; - - var parts = header.FirstOrDefault()?.Split(new[] { ' ' }, 2); - if (parts?.Length != 2) - return null; - - if (parts[0] != "Bearer") - return null; - - var accessToken = parts[1].Trim(); - Require.That(!string.IsNullOrEmpty(accessToken), new UnAuthorizedException()); - - return accessToken; - } - - /// - /// Get the header "X-oc-hash" of the provided request. Used to verify the request originated from OrderCloud. - /// - public static string GetWebhookHash(HttpRequest request) - { - var sentHash = request.Headers?["X-oc-hash"].FirstOrDefault(); - Require.That(!string.IsNullOrEmpty(sentHash), new WebhookUnauthorizedException()); - return sentHash; - } - - /// - /// Get the header "X-oc-hash" of the current HttpContext Request. Used to verify the request originated from OrderCloud. - /// - public string GetWebhookHash() - { - return GetWebhookHash(_httpContextAccessor.HttpContext.Request); - } - - /// - /// Get a raw OrderCloud token from the current HttpContext request headers - /// - public string GetToken() - { - return GetToken(_httpContextAccessor.HttpContext.Request); - } - - /// - /// Get a strongly typed model of the OrderCloud token from the provided request headers - /// - public static DecodedToken GetDecodedToken(HttpRequest request) - { - var token = GetToken(request); - return new DecodedToken(token); - } - - /// - /// Get a strongly typed model of the OrderCloud token from the current HttpContext request headers - /// - public DecodedToken GetDecodedToken() - { - return GetDecodedToken(_httpContextAccessor.HttpContext.Request); - } - - /// - /// Verify the provided OrderCloud token. Throws 401 if invalid or 403 if insufficient roles. - /// - public async Task VerifyTokenAsync(string token, OrderCloudUserAuthOptions options, IEnumerable requiredRoles = null, IEnumerable allowedUserTypes = null) - { - Require.That(!string.IsNullOrEmpty(token), new UnAuthorizedException()); - - var decodedToken = new DecodedToken(token); - - Require.That(decodedToken.ClientID != null, new UnAuthorizedException()); - - Require.That(options.AnyClientIDCanAccess || options.ValidClientIDs.Contains(decodedToken.ClientID, StringComparer.InvariantCultureIgnoreCase), new UnAuthorizedException()); - - Require.That(decodedToken.NotValidBeforeUTC < DateTime.UtcNow && decodedToken.ExpiresUTC > DateTime.UtcNow, - new UnAuthorizedException()); - - // we've validated the token as much as we can on this end, go make sure it's ok on OC - bool isValid; - // some valid tokens - e.g. those from the portal - do not have a "kid" - if (decodedToken.KeyID == null) - { - isValid = await VerifyTokenWithMeGet(decodedToken); // also sets meUser field; - } - else - { - isValid = await VerifyTokenWithKeyID(decodedToken); - } - - if (!isValid) - { - Require.That(decodedToken.ApiUrl == _oc?.Config?.ApiUrl, - new WrongEnvironmentException(new WrongEnvironmentError() - { - ExpectedEnvironment = _oc?.Config?.ApiUrl, - TokenIssuerEnvironment = decodedToken.ApiUrl - } - )); - } - - Require.That(isValid, new UnAuthorizedException()); - - Require.That(allowedUserTypes.IsNullOrEmpty() || allowedUserTypes.Contains(decodedToken.CommerceRole), - new InvalidUserTypeException(new InvalidUserTypeError() - { - ThisUserType = decodedToken?.CommerceRole.ToString(), - UserTypesThatCanAccess = allowedUserTypes?.Select(x => x.ToString())?.ToList() - }) - ); - - Require.That(requiredRoles.IsNullOrEmpty() || requiredRoles.Any(role => decodedToken.Roles.Contains(role)), - new InsufficientRolesException(new InsufficientRolesError() - { - SufficientRoles = requiredRoles?.ToList(), - AssignedRoles = decodedToken.Roles.ToList() - }) - ); - - return decodedToken; - } - - /// - /// Verify the provided HttpRequest's OrderCloud Token. Throws 401 if invalid or 403 if insufficient roles. - /// - public async Task VerifyTokenAsync(HttpRequest request, OrderCloudUserAuthOptions options, IEnumerable requiredRoles = null, IEnumerable allowedUserTypes = null) - { - var token = GetToken(request); - return await VerifyTokenAsync(token, options, requiredRoles, allowedUserTypes); - } - - - /// - /// Verify the current HttpContext request's OrderCloud token. Throws 401 if invalid or 403 if insufficient roles. - /// - public async Task VerifyTokenAsync(OrderCloudUserAuthOptions options, IEnumerable requiredRoles = null, IEnumerable allowedUserTypes = null) - { - return await VerifyTokenAsync(_httpContextAccessor.HttpContext.Request, options, requiredRoles, allowedUserTypes); - } - - /// - /// Verify the provided webhook hash. Proves the request originated from OrderCloud. - /// - public bool VerifyWebhookHashAsync(string requestHash, string requestBody, OrderCloudWebhookAuthOptions options) - { - Require.That(!string.IsNullOrEmpty(options.HashKey), - new InvalidOperationException("OrderCloudWebhookAuthOptions.HashKey was not configured.")); - - Require.That(!string.IsNullOrEmpty(requestBody), new WebhookUnauthorizedException()); - Require.That(!string.IsNullOrEmpty(requestHash), new WebhookUnauthorizedException()); - - var bodyBytes = Encoding.UTF8.GetBytes(requestBody); - var keyBytes = Encoding.UTF8.GetBytes(options.HashKey); - var hash = new HMACSHA256(keyBytes).ComputeHash(bodyBytes); - var computed = Convert.ToBase64String(hash); - - Require.That(requestHash == computed, new WebhookUnauthorizedException()); - return true; - } - - - /// - /// Verify the provided webhook hash. Proves the request originated from OrderCloud. - /// - public async Task VerifyWebhookHashAsync(string requestHash, HttpRequest request, OrderCloudWebhookAuthOptions options) - { - var requestBody = await GetHttpRequestBody(request); - return VerifyWebhookHashAsync(requestHash, requestBody, options); - } - - /// - /// Verify the provided HttpContext request's webhook hash. Proves the request came from OrderCloud. - /// - public async Task VerifyWebhookHashAsync(HttpRequest request, OrderCloudWebhookAuthOptions options) - { - var requestHash = GetWebhookHash(request); - return await VerifyWebhookHashAsync(requestHash, request, options); - } - - /// - /// Verify the current HttpContext request's webhook hash. Proves the request came from OrderCloud. - /// - public async Task VerifyWebhookHashAsync(OrderCloudWebhookAuthOptions options) - { - return await VerifyWebhookHashAsync(_httpContextAccessor.HttpContext.Request, options); - - } - - /// - /// This still won't work inside a controller unless there's middleware to run request.EnableBuffering(); - /// See https://stackoverflow.com/questions/59185410/request-body-from-is-empty-in-net-core-3-0 - /// - private async Task GetHttpRequestBody(HttpRequest request) - { - request.EnableBuffering(); - request.Body.Position = 0; - try - { - return await new StreamReader(request.Body).ReadToEndAsync(); - } - finally - { - request.Body.Position = 0; - } - } - - /// - /// Get the full details of the currently authenticated user based on the HttpContext request token - /// - public async Task GetUserAsync() - where T : MeUser - { - var token = GetToken(); - return await _oc.Me.GetAsync(token); - } - - - /// - /// Get the full details of the currently authenticated user based on the HttpContext request token - /// - public async Task GetUserAsync() - { - var token = GetToken(); - return await _oc.Me.GetAsync(token); - } - - /// - /// Get an IOrderCloudClient with token set based on the HttpContext request - /// - public IOrderCloudClient BuildClient() - { - return GetDecodedToken().BuildClient(); - } - - /// - /// Verifiy the validity of an OrderCloud token, given details about the public key. - /// - public static bool VerifyTokenWithPublicKey(string accessToken, PublicKey publicKey) - { - if (publicKey == null) - { - return false; - } - var rsa = new RSACryptoServiceProvider(2048); - rsa.ImportParameters(new RSAParameters - { - Modulus = FromBase64Url(publicKey.n), - Exponent = FromBase64Url(publicKey.e) - }); - var rsaSecurityKey = new RsaSecurityKey(rsa); - - var result = new JsonWebTokenHandler().ValidateToken(accessToken, new TokenValidationParameters - { - IssuerSigningKey = rsaSecurityKey, - RequireSignedTokens = true, - ValidateIssuerSigningKey = true, - ValidateLifetime = true, - LifetimeValidator = (nbf, exp, _, __) => nbf < DateTime.UtcNow && exp > DateTime.UtcNow, - ValidateIssuer = false, - RequireExpirationTime = true, - ValidateAudience = false - }); - return result.IsValid; - } - - private async Task VerifyTokenWithMeGet(DecodedToken jwt) - { - var cacheKey = jwt.AccessToken; - - return await _cache.GetOrAddAsync(cacheKey, TimeSpan.FromHours(1), async () => - { - try - { - var meUser = await _oc.Me.GetAsync(jwt.AccessToken); - return meUser != null && meUser.Active; - } - catch (OrderCloudException ex) - { - throw ex; - } - catch (Exception ex) - { - await _cache.RemoveAsync(cacheKey); // not their fault, don't make them wait 1 hr - return false; - } - }); - } - - private async Task VerifyTokenWithKeyID(DecodedToken jwt) - { - var cacheKey = $"{jwt.ApiUrl}-{jwt.KeyID}"; - var publicKey = await _cache.GetOrAddAsync(cacheKey, TimeSpan.FromDays(30), async () => - { - try - { - return await _oc.GetPublicKeyAsync(jwt.KeyID); - } - catch (OrderCloudException ex) - { - throw ex; - } - catch (Exception ex) - { - await _cache.RemoveAsync(cacheKey); // not their fault, don't make them wait 5 min - return null; // null public key will lead to unauthorized exception; - } - }); - return VerifyTokenWithPublicKey(jwt.AccessToken, publicKey); - } - - private static byte[] FromBase64Url(string base64Url) - { - string padded = base64Url.Length % 4 == 0 - ? base64Url : base64Url + "====".Substring(base64Url.Length % 4); - string base64 = padded.Replace("_", "/") - .Replace("-", "+"); - return Convert.FromBase64String(base64); - } - } -} diff --git a/OrderCloud.Catalyst/Auth/UserInfoAuth/DecodedUserInfoToken.cs b/OrderCloud.Catalyst/Auth/UserInfoAuth/DecodedUserInfoToken.cs new file mode 100644 index 0000000..b3426ea --- /dev/null +++ b/OrderCloud.Catalyst/Auth/UserInfoAuth/DecodedUserInfoToken.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; + + +namespace OrderCloud.Catalyst +{ + /// + /// Represents data inside a specific OrderCloud userinfo json web token + /// + public class DecodedUserInfoToken + { + /// + /// The raw jwt userinfo token + /// + public string UserInfoToken { get; } + + /// + /// The signing key ID of the token. "kid" claim. Null when Portal issued the token. + /// + public string KeyID { get; } + + /// + /// MarketplaceID on the token. "marketplaceID" claim. Always non-null. + /// + public string MarketplaceID { get; } + + /// + /// Username on the token. "sub" claim. Always non-null. + /// + public string Username { get; } + + /// + /// OrderCloud roles on the token. "availableroles" claim. Always non-null. + /// + public List Roles { get; } = new List(); + + /// + /// Groups on the token. "groups" claim. Always non-null. + /// + public List Groups { get; } = new List(); + + /// + /// CompanyID on the token. "companyID" claim. Always non-null. + /// + public string CompanyID { get; } + + /// + /// The authentication Url on the token. "iss" claim. Always non-null. + /// + public string AuthUrl { get; } + + /// + /// The api Url on the token. "aud" claim. Always non-null. + /// + public string ApiUrl { get; } + + /// + /// The expiry time of the token. "exp" claim. Always non-null. + /// + public DateTime ExpiresUTC { get; } + + /// + /// The time the token is not valid before. "nbf" claim. Always non-null. + /// + public DateTime NotValidBeforeUTC { get; } + + public DecodedUserInfoToken() { } + + /// + /// Create a UserContext from a raw json web token. + /// + public DecodedUserInfoToken(string token) + { + var jwt = new JwtSecurityToken(token); + var lookup = jwt.Claims.ToLookup(c => c.Type, c => c.Value); + + UserInfoToken = token; + KeyID = GetHeader(jwt, "kid"); + + MarketplaceID = lookup["marketplaceID"].FirstOrDefault(); + Username = lookup["sub"].FirstOrDefault(); + Roles = lookup["availableroles"].ToList(); + Groups = lookup["groups"].ToList(); + CompanyID = lookup["companyID"].FirstOrDefault(); + AuthUrl = lookup["iss"].FirstOrDefault(); + ApiUrl = lookup["aud"].FirstOrDefault(); + ExpiresUTC = int.Parse(lookup["exp"].FirstOrDefault() ?? throw new ArgumentNullException("Token must contain \"exp\" claim")).FromUnixEpoch(); + NotValidBeforeUTC = int.Parse(lookup["nbf"].FirstOrDefault() ?? throw new ArgumentNullException("Token must contain \"nbf\" claim")).FromUnixEpoch(); + } + private static string GetHeader(JwtSecurityToken jwt, string key) + { + return jwt.Header.FirstOrDefault(t => t.Key == key).Value?.ToString(); + } + } +} diff --git a/OrderCloud.Catalyst/Auth/UserInfoAuth/OrderCloudUserInfoAuth.cs b/OrderCloud.Catalyst/Auth/UserInfoAuth/OrderCloudUserInfoAuth.cs new file mode 100644 index 0000000..25c797c --- /dev/null +++ b/OrderCloud.Catalyst/Auth/UserInfoAuth/OrderCloudUserInfoAuth.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OrderCloud.SDK; + +namespace OrderCloud.Catalyst +{ + /// + /// Apply to controllers or actions to require that a valid OrderCloud access token is provided in the Authorization header. + /// + public class OrderCloudUserInfoAuthAttribute : AuthorizeAttribute + { + public List OrderCloudRoles => Roles?.Split(',')?.ToList() ?? new List { }; + + public OrderCloudUserInfoAuthAttribute() + { + AuthenticationSchemes = "OrderCloudUserInfo"; + } + + /// Optional list of roles. If provided, user must have just one of them, otherwise authorization fails. + public OrderCloudUserInfoAuthAttribute(params ApiRole[] roles) + { + AuthenticationSchemes = "OrderCloudUserInfo"; + var rolesList = roles.ToList(); + rolesList.Add(ApiRole.FullAccess); + Roles = string.Join(",", rolesList); + } + + /// Optional list of roles. If provided, user must have just one of them, otherwise authorization fails. + public OrderCloudUserInfoAuthAttribute(params string[] roles) + { + AuthenticationSchemes = "OrderCloudUserInfo"; + var rolesList = roles.ToList(); + rolesList.Add("FullAccess"); + Roles = string.Join(",", rolesList); + } + } + + public class OrderCloudUserInfoAuthHandler : AuthenticationHandler + { + private readonly IRequestAuthenticationService _auth; + + public OrderCloudUserInfoAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock, + IRequestAuthenticationService auth + ) + : base(options, logger, encoder, clock) + { + _auth = auth; + } + + protected override async Task HandleAuthenticateAsync() + { + try + { + var requiredRoles = Context.GetRequiredOrderCloudRoles(); + var token = await _auth.VerifyUserInfoTokenAsync(Request, requiredRoles); + var cid = new ClaimsIdentity("OcUserInfo"); + cid.AddClaims(token.Roles.Select(r => new Claim(ClaimTypes.Role, r))); + cid.AddClaim(new Claim("UserInfoToken", token.UserInfoToken)); + + var ticket = new AuthenticationTicket(new ClaimsPrincipal(cid), "OcUserInfo"); + return AuthenticateResult.Success(ticket); + } + catch (CatalystBaseException ex) + { + throw ex; + } + catch (OrderCloudException ex) + { + throw ex; + } + catch (Exception ex) + { + throw new UnAuthorizedException(); + } + } + } + + public class OrderCloudUserInfoAuthOptions : AuthenticationSchemeOptions + { + + } +} diff --git a/OrderCloud.Catalyst/Auth/UserInfoAuth/README.md b/OrderCloud.Catalyst/Auth/UserInfoAuth/README.md new file mode 100644 index 0000000..8b680cf --- /dev/null +++ b/OrderCloud.Catalyst/Auth/UserInfoAuth/README.md @@ -0,0 +1,107 @@ +## OrderCloud UserInfo Authentication + +When a user authenticates and acquires a userinfo token from OrderCloud, typically in a front-end web or mobile app, that token can be used in your custom endpoints to verify the user's identity and roles. Here are the steps involved: + +#### 1. Register OrderCloud user authentication and register user context in your [`Startup`](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/startup) class. + +```c# +// In Startup.cs +public virtual void ConfigureServices(IServiceCollection services) { + services.AddOrderCloudUserInfoAuth(); +} +``` +#### 2. In your front-end app, anywhere you call one of your custom endpoints, pass the OrderCloud userinfo token in a request header. + +``` +Authorization: Bearer my-ordercloud-userinfo-token +``` + +#### 3. Mark any of your controllers or action methods with `[OrderCloudUserInfoAuth]`. Use the CatalystBaseController property `UserInfoContext`. + +Optionally, You may provide one or more required roles in this attribute, **any one of which** the user must be assigned in order for authorization to succeed. + +```c# +public class MyThingController : CatalystController +{ + // Without access, requestor recieves a 401 Unauthorized or 403 InsufficientRoles error. + [HttpGet, Route("thing")] + [OrderCloudUserInfoAuth(ApiRole.Shopper, ApiRole.OrderReader, ApiRole.OrderAdmin)] // Any one of these threee roles gives access the endpoint + public Thing Get(string id) { + var username = UserInfoContext.Username; // UserInfoContext is a property on CatalystController + ... + } +} +``` + +Define custom roles that are meaningful in your app's context. +Give users access to these roles through https://ordercloud.io/api-reference/authentication-and-authorization/security-profiles/create +```c# + [HttpPut, Route("thing")] + [OrderCloudUserInfoAuth("ThingAdmin")] // The role "ThingAdmin" is a custom developer-defined role + public void Edit([FromBody] Thing thing) { + ... + } +``` + +Access data in the claims of the OrderCloud token used in the request. +```c# + [HttpPut, Route("hello")] + [OrderCloudUserInfoAuth] // No roles are defined, so any valid OrderCloud Token gives access. + public string Hello([FromBody] Thing thing) { + return $"Hello {UserInfoContext.Username}, you belong to groups {string.Join(",", UserInfoContext.Groups)}";. + } +``` + +### DecodedUserInfoToken and IRequestAuthenticationService + +The primary way to enforce authentication in controllers is by using the [OrderCloudUserInfoAuth] attribute on your controller actions. This automatically validates tokens and injects user info context for you. + +However, if you prefer to handle authentication inside a service (rather than directly in the controller), IRequestAuthenticationService offers an alternative approach. It allows you to verify tokens and retrieve user details programmatically. + +> **Important:** +IRequestAuthenticationService depends on HttpContext and should only be used within the ASP.NET Core pipeline (e.g., controllers, middleware, or services called from them). +For environments without HttpContext (such as Azure Functions, background services, or console apps), use ITokenValidator instead. ITokenValidator works independently of the current request + + +#### Using IRequestAuthenticationService (ASP.NET Core) +```c# + string rawToken = "..."; + // Parses the token, but does not verify it. + DecodedUserInfoToken context = new DecodedUserInfoToken(rawToken); + // Only data on the token is available. user.FirstName and user.xp are not, for example. + console.log(user.Username) + + // Inject a IRequestAuthenticationService to verify. [OrderCloudUserInfoAuth] uses this method under the hood. + DecodedUserInfoToken verified = await _requestAuthenticationService.VerifyUserInfoTokenAsync(rawToken); + + // IRequestAuthenticationService can also get a DecodedUserInfoToken from the current HttpContext. + // Only use this after calling VerifyUserInfoTokenAsync, either directly or through [OrderCloudUserInfoAuth]. + DecodedUserInfoToken unverified = await _requestAuthenticationService.GetDecodedUserInfoToken(); + +``` + + +### Using ITokenValidator (Non-ASP.NET Contexts like Azure Functions) + +ITokenValidator does not depend on HttpContext and works anywhere you have the raw token: + + +```c# +public class TokenValidationExample +{ + private readonly ITokenValidator _tokenValidator; + + public TokenValidationExample(ITokenValidator tokenValidator) + { + _tokenValidator = tokenValidator; + } + + public async Task ValidateTokenAsync(string rawToken) + { + // Validate token and enforce roles/user types if needed + DecodedUserInfoToken decoded = await _tokenValidator.ValidateUserInfoTokenAsync(rawToken); + + Console.WriteLine($"Token is valid for user: {decoded.Username}"); + } +} +``` \ No newline at end of file diff --git a/OrderCloud.Catalyst/Auth/WebhookAuth/OrderCloudWebhookAuth.cs b/OrderCloud.Catalyst/Auth/WebhookAuth/OrderCloudWebhookAuth.cs index 8f17f8e..cac45a4 100644 --- a/OrderCloud.Catalyst/Auth/WebhookAuth/OrderCloudWebhookAuth.cs +++ b/OrderCloud.Catalyst/Auth/WebhookAuth/OrderCloudWebhookAuth.cs @@ -1,14 +1,8 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Security.Claims; - +using System.Security.Claims; using System.Text.Encodings.Web; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -26,21 +20,21 @@ public OrderCloudWebhookAuthAttribute() { public class OrderCloudWebhookAuthHandler : AuthenticationHandler { - private static RequestAuthenticationService _requestAuthenticationService; + private readonly IRequestAuthenticationService _auth; public OrderCloudWebhookAuthHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, - RequestAuthenticationService requestAuthenticationService + IRequestAuthenticationService auth ) : base(options, logger, encoder, clock) { - _requestAuthenticationService = requestAuthenticationService; + _auth = auth; } protected override async Task HandleAuthenticateAsync() { - await _requestAuthenticationService.VerifyWebhookHashAsync(Request, Options); // Will throw error if fails + await _auth.VerifyWebhookHashAsync(Request, Options); // Will throw error if fails var cid = new ClaimsIdentity("OcWebhook"); var ticket = new AuthenticationTicket(new ClaimsPrincipal(cid), "OcWebhook"); return AuthenticateResult.Success(ticket); diff --git a/OrderCloud.Catalyst/Auth/WebhookAuth/README.md b/OrderCloud.Catalyst/Auth/WebhookAuth/README.md index e84d813..88f7131 100644 --- a/OrderCloud.Catalyst/Auth/WebhookAuth/README.md +++ b/OrderCloud.Catalyst/Auth/WebhookAuth/README.md @@ -26,7 +26,7 @@ public object HandleAddressSave([FromBody] WebhookPayloads.Addresses.Save public static IServiceCollection AddOrderCloudUserAuth(this IServiceCollection services, Action configureOptions) { - services - .AddHttpContextAccessor() - .AddSingleton() - .AddSingleton() // Can override by registering own implmentation - .AddAuthentication() - .AddScheme("OrderCloudUser", null, configureOptions); - return services; + services.AddOrderCloudSharedAuthServices(); + + services + .AddHttpContextAccessor() + .AddAuthentication() + .AddScheme("OrderCloudUser", configureOptions); + + return services; } - /// - /// Chain to IServiceCollection (typically in Startup.ConfigureServices) to enable validation of incoming webhooks. - /// - public static IServiceCollection AddOrderCloudWebhookAuth(this IServiceCollection services, Action configureOptions) + /// + /// Chain to IServiceCollection (typically in Startup.ConfigureServices) to enable authenticating by passing a valid + /// OrderCloud userinfo token in the Authorization header. Add [OrderCloudUserInfoAuth] attribute to specific controllers or actions + /// where this should be enforced. Typical use case is custom endpoints for front-end user apps. + /// + public static IServiceCollection AddOrderCloudUserInfoAuth(this IServiceCollection services) + { + services.AddOrderCloudSharedAuthServices(); + + services + .AddHttpContextAccessor() + .AddAuthentication() + .AddScheme("OrderCloudUserInfo", options => { }); + + return services; + } + + + /// + /// Shared infrastructure needed by both OrderCloudUser and OrderCloudUserInfo schemes. + /// Uses TryAddSingleton to avoid duplicate singletons when both methods are called. + /// + private static IServiceCollection AddOrderCloudSharedAuthServices(this IServiceCollection services) + { + services.AddHttpContextAccessor(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); // Can override by registering your own implementation first + return services; + } + + /// + /// Chain to IServiceCollection (typically in Startup.ConfigureServices) to enable validation of incoming webhooks. + /// + public static IServiceCollection AddOrderCloudWebhookAuth(this IServiceCollection services, Action configureOptions) { services.AddAuthentication() .AddScheme("OrderCloudWebhook", null, configureOptions); @@ -61,10 +94,22 @@ public static List GetRequiredOrderCloudRoles(this HttpContext context) .ToList(); } - /// - /// Looks for a UserTypeRestrictedToAttribute on the current route to find allowed user types. - /// - public static List GetAllowedUserTypes(this HttpContext context) + /// + /// Looks for an OrderCloudUserInfoAuthAttribute on the current route to find required roles. + /// + public static List GetRequiredUserInfoRoles(this HttpContext context) + { + var endpointFeature = context.Features[typeof(IEndpointFeature)] as IEndpointFeature; + return endpointFeature?.Endpoint?.Metadata + .Where(x => x.GetType() == typeof(OrderCloudUserInfoAuthAttribute)) + .SelectMany(x => (x as OrderCloudUserInfoAuthAttribute).OrderCloudRoles) + .ToList(); + } + + /// + /// Looks for a UserTypeRestrictedToAttribute on the current route to find allowed user types. + /// + public static List GetAllowedUserTypes(this HttpContext context) { var endpointFeature = context.Features[typeof(IEndpointFeature)] as IEndpointFeature; diff --git a/README.md b/README.md index 950873d..cebbf67 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,7 @@ httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); ``` +Similarly, for `[OrderCloudUserInfoToken]` you can use `FakeUserInfoToken` ### Progress tracker