From b8a64ca3887298feccef5185f6bfec4c3771b5a9 Mon Sep 17 00:00:00 2001 From: Mateusz Woda Date: Mon, 7 Feb 2022 01:39:36 -0800 Subject: [PATCH] feat: add Client Credentials Grant auth support (#799) --- Box.V2.Test/BoxCCGAuthTest.cs | 120 ++++++++++++++++++ Box.V2.Test/CCGAuthRepositoryTest.cs | 102 +++++++++++++++ .../Extensions/DictionaryExtensions.cs | 14 ++ Box.V2/Box.V2.csproj | 4 +- Box.V2/CCGAuth/BoxCCGAuth.cs | 114 +++++++++++++++++ Box.V2/CCGAuth/CCGAuthRepository.cs | 91 +++++++++++++ Box.V2/Config/BoxConfigBuilder.cs | 24 ++++ Box.V2/Config/Constants.cs | 9 ++ 8 files changed, 477 insertions(+), 1 deletion(-) create mode 100644 Box.V2.Test/BoxCCGAuthTest.cs create mode 100644 Box.V2.Test/CCGAuthRepositoryTest.cs create mode 100644 Box.V2.Test/Extensions/DictionaryExtensions.cs create mode 100644 Box.V2/CCGAuth/BoxCCGAuth.cs create mode 100644 Box.V2/CCGAuth/CCGAuthRepository.cs diff --git a/Box.V2.Test/BoxCCGAuthTest.cs b/Box.V2.Test/BoxCCGAuthTest.cs new file mode 100644 index 000000000..c470471cc --- /dev/null +++ b/Box.V2.Test/BoxCCGAuthTest.cs @@ -0,0 +1,120 @@ +using System; +using System.Threading.Tasks; +using Box.V2.Auth; +using Box.V2.CCGAuth; +using Box.V2.Config; +using Box.V2.Request; +using Box.V2.Services; +using Box.V2.Test.Extensions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Box.V2.Test +{ + [TestClass] + public class BoxCCGAuthTest : BoxResourceManagerTest + { + private readonly Mock _handler; + private readonly IBoxService _service; + private readonly Mock _boxConfig; + private readonly BoxCCGAuth _ccgAuth; + + public BoxCCGAuthTest() + { + // Initial Setup + _handler = new Mock(); + _service = new BoxService(_handler.Object); + _boxConfig = new Mock(); + _boxConfig.SetupGet(x => x.EnterpriseId).Returns("12345"); + _boxConfig.SetupGet(x => x.ClientId).Returns("123"); + _boxConfig.SetupGet(x => x.ClientSecret).Returns("SECRET"); + _boxConfig.SetupGet(x => x.BoxApiHostUri).Returns(new Uri(Constants.BoxApiHostUriString)); + _boxConfig.SetupGet(x => x.BoxAuthTokenApiUri).Returns(new Uri(Constants.BoxAuthTokenApiUriString)); + _ccgAuth = new BoxCCGAuth(_boxConfig.Object, _service); + } + + [TestMethod] + [TestCategory("CI-UNIT-TEST")] + public async Task GetAdminToken_ValidSession() + { + // Arrange + IBoxRequest boxRequest = null; + _handler.Setup(h => h.ExecuteAsync(It.IsAny())) + .Returns(Task>.Factory.StartNew(() => new BoxResponse() + { + Status = ResponseStatus.Success, + ContentString = "{\"access_token\":\"T9cE5asGnuyYCCqIZFoWjFHvNbvVqHjl\",\"expires_in\":3600,\"restricted_to\":[],\"token_type\":\"bearer\"}" + })) + .Callback(r => boxRequest = r); + + // Act + var accessToken = await _ccgAuth.AdminTokenAsync(); + + // Assert + Assert.AreEqual("https://api.box.com/oauth2/token", boxRequest.AbsoluteUri.AbsoluteUri); + Assert.AreEqual(RequestMethod.Post, boxRequest.Method); + Assert.IsTrue(boxRequest.PayloadParameters.ContainsKeyValue("grant_type", "client_credentials")); + Assert.IsTrue(boxRequest.PayloadParameters.ContainsKeyValue("client_id", _boxConfig.Object.ClientId)); + Assert.IsTrue(boxRequest.PayloadParameters.ContainsKeyValue("client_secret", _boxConfig.Object.ClientSecret)); + Assert.IsTrue(boxRequest.PayloadParameters.ContainsKeyValue("box_subject_type", "enterprise")); + Assert.IsTrue(boxRequest.PayloadParameters.ContainsKeyValue("box_subject_id", _boxConfig.Object.EnterpriseId)); + + Assert.AreEqual(accessToken, "T9cE5asGnuyYCCqIZFoWjFHvNbvVqHjl"); + } + + [TestMethod] + [TestCategory("CI-UNIT-TEST")] + public async Task GetUserToken_ValidSession() + { + // Arrange + var userId = "22222"; + IBoxRequest boxRequest = null; + _handler.Setup(h => h.ExecuteAsync(It.IsAny())) + .Returns(Task>.Factory.StartNew(() => new BoxResponse() + { + Status = ResponseStatus.Success, + ContentString = "{\"access_token\":\"T9cE5asGnuyYCCqIZFoWjFHvNbvVqHjl\",\"expires_in\":3600,\"restricted_to\":[],\"token_type\":\"bearer\"}" + })) + .Callback(r => boxRequest = r); + + // Act + var accessToken = await _ccgAuth.UserTokenAsync(userId); + Assert.AreEqual("https://api.box.com/oauth2/token", boxRequest.AbsoluteUri.AbsoluteUri); + Assert.AreEqual(RequestMethod.Post, boxRequest.Method); + Assert.IsTrue(boxRequest.PayloadParameters.ContainsKeyValue("grant_type", "client_credentials")); + Assert.IsTrue(boxRequest.PayloadParameters.ContainsKeyValue("client_id", _boxConfig.Object.ClientId)); + Assert.IsTrue(boxRequest.PayloadParameters.ContainsKeyValue("client_secret", _boxConfig.Object.ClientSecret)); + Assert.IsTrue(boxRequest.PayloadParameters.ContainsKeyValue("box_subject_type", "user")); + Assert.IsTrue(boxRequest.PayloadParameters.ContainsKeyValue("box_subject_id", userId)); + + // Assert + Assert.AreEqual(accessToken, "T9cE5asGnuyYCCqIZFoWjFHvNbvVqHjl"); + } + + [TestMethod] + [TestCategory("CI-UNIT-TEST")] + public void UserClient_ShouldReturnUserClientWithSession() + { + // Act + var userClient = _ccgAuth.UserClient("T9cE5asGnuyYCCqIZFoWjFHvNbvVqHjl", "22222"); + + // Assert + Assert.IsInstanceOfType(userClient, typeof(BoxClient)); + Assert.IsInstanceOfType(userClient.Auth, typeof(CCGAuthRepository)); + Assert.IsNotNull(userClient.Auth.Session); + } + + [TestMethod] + [TestCategory("CI-UNIT-TEST")] + public void AdminClient_ShouldReturnAdminClientWithSession() + { + // Act + var adminClient = _ccgAuth.AdminClient("T9cE5asGnuyYCCqIZFoWjFHvNbvVqHjl", "22222", true); + + // Assert + Assert.IsInstanceOfType(adminClient, typeof(BoxClient)); + Assert.IsInstanceOfType(adminClient.Auth, typeof(CCGAuthRepository)); + Assert.IsNotNull(adminClient.Auth.Session); + } + } +} diff --git a/Box.V2.Test/CCGAuthRepositoryTest.cs b/Box.V2.Test/CCGAuthRepositoryTest.cs new file mode 100644 index 000000000..91a852487 --- /dev/null +++ b/Box.V2.Test/CCGAuthRepositoryTest.cs @@ -0,0 +1,102 @@ +using System; +using System.Threading.Tasks; +using Box.V2.Auth; +using Box.V2.CCGAuth; +using Box.V2.Config; +using Box.V2.Request; +using Box.V2.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + + +namespace Box.V2.Test +{ + [TestClass] + public class CCGAuthRepositoryTest : BoxResourceManagerTest + { + private readonly CCGAuthRepository _userAuthRepository; + private readonly CCGAuthRepository _adminAuthRepository; + private readonly Mock _boxConfig; + private readonly Mock _handler; + private readonly IBoxService _service; + private readonly BoxCCGAuth _ccgAuth; + + private readonly string _userId = "22222"; + + public CCGAuthRepositoryTest() + { + // Initial Setup + _handler = new Mock(); + _service = new BoxService(_handler.Object); + _boxConfig = new Mock(); + _boxConfig.SetupGet(x => x.EnterpriseId).Returns("12345"); + _boxConfig.SetupGet(x => x.ClientId).Returns("123"); + _boxConfig.SetupGet(x => x.ClientSecret).Returns("SECRET"); + _boxConfig.SetupGet(x => x.BoxApiHostUri).Returns(new Uri(Constants.BoxApiHostUriString)); + _boxConfig.SetupGet(x => x.BoxAuthTokenApiUri).Returns(new Uri(Constants.BoxAuthTokenApiUriString)); + _ccgAuth = new BoxCCGAuth(_boxConfig.Object, _service); + _userAuthRepository = new CCGAuthRepository(null, _ccgAuth, _userId); + _adminAuthRepository = new CCGAuthRepository(null, _ccgAuth, _userId); + } + + [TestMethod] + [TestCategory("CI-UNIT-TEST")] + public async Task RefreshAccessTokenAsync_ForUser_ReturnsUserSession() + { + // Arrange + _handler.Setup(h => h.ExecuteAsync(It.IsAny())) + .Returns(Task>.Factory.StartNew(() => new BoxResponse() + { + Status = ResponseStatus.Success, + ContentString = "{\"access_token\":\"T9cE5asGnuyYCCqIZFoWjFHvNbvVqHjl\",\"expires_in\":3600,\"restricted_to\":[],\"token_type\":\"bearer\"}" + })); + + // Act + var session = await _userAuthRepository.RefreshAccessTokenAsync(null); + + // Assert + Assert.AreEqual(session.AccessToken, "T9cE5asGnuyYCCqIZFoWjFHvNbvVqHjl"); + Assert.AreEqual(session.TokenType, "bearer"); + Assert.AreEqual(session.RefreshToken, null); + Assert.AreEqual(session.ExpiresIn, 3600); + } + + [TestMethod] + [TestCategory("CI-UNIT-TEST")] + public async Task RefreshAccessTokenAsync_ForAdmin_ReturnsAdminSession() + { + // Arrange + _handler.Setup(h => h.ExecuteAsync(It.IsAny())) + .Returns(Task>.Factory.StartNew(() => new BoxResponse() + { + Status = ResponseStatus.Success, + ContentString = "{\"access_token\":\"T9cE5asGnuyYCCqIZFoWjFHvNbvVqHjl\",\"expires_in\":3600,\"restricted_to\":[],\"token_type\":\"bearer\"}" + })); + + // Act + var session = await _adminAuthRepository.RefreshAccessTokenAsync(null); + + // Assert + Assert.AreEqual(session.AccessToken, "T9cE5asGnuyYCCqIZFoWjFHvNbvVqHjl"); + Assert.AreEqual(session.TokenType, "bearer"); + Assert.AreEqual(session.RefreshToken, null); + Assert.AreEqual(session.ExpiresIn, 3600); + } + + [TestMethod] + [TestCategory("CI-UNIT-TEST")] + public void LogoutAsync_ThrowsException() + { + // Act & Assert + Assert.ThrowsExceptionAsync(() => _adminAuthRepository.LogoutAsync()); + } + + [TestMethod] + [TestCategory("CI-UNIT-TEST")] + public void AuthenticateAsync_ThrowsException() + { + // Act & Assert + Assert.ThrowsExceptionAsync(() => _adminAuthRepository.AuthenticateAsync(null)); + } + } +} diff --git a/Box.V2.Test/Extensions/DictionaryExtensions.cs b/Box.V2.Test/Extensions/DictionaryExtensions.cs new file mode 100644 index 000000000..0ab8674cd --- /dev/null +++ b/Box.V2.Test/Extensions/DictionaryExtensions.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; + +namespace Box.V2.Test.Extensions +{ + public static class DictionaryExtensions + { + public static bool ContainsKeyValue(this Dictionary dictionary, + T expectedKey, T expectedValue) where T : IEquatable + { + return dictionary.TryGetValue(expectedKey, out T actualValue) && EqualityComparer.Default.Equals(actualValue, expectedValue); + } + } +} diff --git a/Box.V2/Box.V2.csproj b/Box.V2/Box.V2.csproj index f5ed6f2f6..b68f6b69e 100644 --- a/Box.V2/Box.V2.csproj +++ b/Box.V2/Box.V2.csproj @@ -33,7 +33,7 @@ prompt 4 - + pdbonly true bin\Release\ @@ -78,6 +78,8 @@ + + diff --git a/Box.V2/CCGAuth/BoxCCGAuth.cs b/Box.V2/CCGAuth/BoxCCGAuth.cs new file mode 100644 index 000000000..fdb2ace86 --- /dev/null +++ b/Box.V2/CCGAuth/BoxCCGAuth.cs @@ -0,0 +1,114 @@ +using System.Threading.Tasks; +using Box.V2.Auth; +using Box.V2.Config; +using Box.V2.Converter; +using Box.V2.Extensions; +using Box.V2.Request; +using Box.V2.Services; + +namespace Box.V2.CCGAuth +{ + public class BoxCCGAuth + { + private readonly IBoxService _boxService; + private readonly IBoxConfig _boxConfig; + + /// + /// Constructor for CCG authentication + /// + /// Config contains information about client id, client secret, enterprise id. + /// Box service is used to perform GetToken requests + public BoxCCGAuth(IBoxConfig boxConfig, IBoxService boxService) + { + _boxConfig = boxConfig; + _boxService = boxService; + } + + /// + /// Constructor for CCG authentication with default boxService + /// + /// Config contains information about client id, client secret, enterprise id. + public BoxCCGAuth(IBoxConfig boxConfig) : this(boxConfig, new BoxService(new HttpRequestHandler(boxConfig.WebProxy, boxConfig.Timeout))) + { + + } + + /// + /// Create admin BoxClient using an admin access token + /// + /// Admin access token + /// The user ID to set as the 'As-User' header parameter; used to make calls in the context of a user using an admin token + /// Whether or not to suppress both email and webhook notifications. Typically used for administrative API calls. Your application must have “Manage an Enterprise” scope, and the user making the API calls is a co-admin with the correct "Edit settings for your company" permission. + /// BoxClient that uses CCG authentication + public IBoxClient AdminClient(string adminToken, string asUser = null, bool? suppressNotifications = null) + { + var adminSession = Session(adminToken); + var authRepo = new CCGAuthRepository(adminSession, this); + var adminClient = new BoxClient(_boxConfig, authRepo, asUser: asUser, suppressNotifications: suppressNotifications); + + return adminClient; + } + + /// + /// Create user BoxClient using a user access token + /// + /// User access token + /// Id of the user + /// BoxClient that uses CCG authentication + public IBoxClient UserClient(string userToken, string userId) + { + var userSession = Session(userToken); + var authRepo = new CCGAuthRepository(userSession, this, userId); + var userClient = new BoxClient(_boxConfig, authRepo); + + return userClient; + } + + /// + /// Get admin token by posting data to auth url + /// + /// Admin token + public async Task AdminTokenAsync() + { + return (await CCGAuthPostAsync(Constants.RequestParameters.EnterpriseSubType, _boxConfig.EnterpriseId).ConfigureAwait(false)).AccessToken; + } + + /// + /// Once you have created an App User or Managed User, you can request a User Access Token via the App Auth feature, which will return the OAuth 2.0 access token for the specified User. + /// + /// Id of the user + /// User token + public async Task UserTokenAsync(string userId) + { + return (await CCGAuthPostAsync(Constants.RequestParameters.UserSubType, userId).ConfigureAwait(false)).AccessToken; + } + + private async Task CCGAuthPostAsync(string subType, string subId) + { + BoxRequest boxRequest = new BoxRequest(_boxConfig.BoxApiHostUri, Constants.AuthTokenEndpointString) + .Method(RequestMethod.Post) + .Payload(Constants.RequestParameters.GrantType, Constants.RequestParameters.ClientCredentials) + .Payload(Constants.RequestParameters.ClientId, _boxConfig.ClientId) + .Payload(Constants.RequestParameters.ClientSecret, _boxConfig.ClientSecret) + .Payload(Constants.RequestParameters.SubjectType, subType) + .Payload(Constants.RequestParameters.SubjectId, subId); + + var converter = new BoxJsonConverter(); + IBoxResponse boxResponse = await _boxService.ToResponseAsync(boxRequest).ConfigureAwait(false); + boxResponse.ParseResults(converter); + + return boxResponse.ResponseObject; + + } + + /// + /// Create OAuth session from token + /// + /// Access token created by method UserToken, or AdminToken + /// OAuth session + public OAuthSession Session(string token) + { + return new OAuthSession(token, null, Constants.AccessTokenExpirationTime, Constants.BearerTokenType); + } + } +} diff --git a/Box.V2/CCGAuth/CCGAuthRepository.cs b/Box.V2/CCGAuth/CCGAuthRepository.cs new file mode 100644 index 000000000..5a28f07d8 --- /dev/null +++ b/Box.V2/CCGAuth/CCGAuthRepository.cs @@ -0,0 +1,91 @@ +using System; +using System.Threading.Tasks; +using Box.V2.Auth; + +namespace Box.V2.CCGAuth +{ + /// + /// CCG auth repository used by an AdminClient or UserClient + /// + public class CCGAuthRepository : IAuthRepository + { + /// + /// OAuth session + /// + public OAuthSession Session { get; private set; } + + /// + /// Box Authentication using a Client Credentials Grant (CCG) + /// + public BoxCCGAuth BoxCCGAuth { get; private set; } + + /// + /// Id of the user + /// + public string UserId { get; private set; } + + /// + /// Event fired when session is invalidated + /// + public event EventHandler SessionInvalidated; + + /// + /// Event fired after authetication + /// + public event EventHandler SessionAuthenticated; + + /// + /// Constructor CCG auth repository + /// + /// OAuth session + /// CCG authentication + /// Id of the user + public CCGAuthRepository(OAuthSession session, BoxCCGAuth boxCCGAuth, string userId = null) + { + Session = session; + BoxCCGAuth = boxCCGAuth; + UserId = userId; + } + + /// + /// Not used for this type of authentication + /// + /// + /// + public Task AuthenticateAsync(string authCode) + { + throw new NotImplementedException(); + } + + /// + /// Not used for this type of authentication + /// + /// + public Task LogoutAsync() + { + throw new NotImplementedException(); + } + + /// + /// Retrieves a new access token using BoxCCGAuth + /// + /// This input is not used. Could be set to null + /// OAuth session + public async Task RefreshAccessTokenAsync(string accessToken) + { + OAuthSession session = UserId != null + ? BoxCCGAuth.Session(await BoxCCGAuth.UserTokenAsync(UserId).ConfigureAwait(false)) + : BoxCCGAuth.Session(await BoxCCGAuth.AdminTokenAsync().ConfigureAwait(false)); + + Session = session; + OnSessionAuthenticated(session); + + return session; + } + + private void OnSessionAuthenticated(OAuthSession session) + { + SessionAuthenticated?.Invoke(this, new SessionAuthenticatedEventArgs(session)); + } + } +} diff --git a/Box.V2/Config/BoxConfigBuilder.cs b/Box.V2/Config/BoxConfigBuilder.cs index f233469a9..0f4e873cf 100644 --- a/Box.V2/Config/BoxConfigBuilder.cs +++ b/Box.V2/Config/BoxConfigBuilder.cs @@ -46,6 +46,19 @@ public BoxConfigBuilder(string clientId, string clientSecret, string enterpriseI UserAgent = BoxConfig.DefaultUserAgent; } + /// + /// Instantiates a BoxConfigBuilder for use with CCG authentication + /// + /// + /// + /// BoxConfigBuilder instance. + public BoxConfigBuilder(string clientId, string clientSecret) + { + ClientId = clientId; + ClientSecret = clientSecret; + UserAgent = BoxConfig.DefaultUserAgent; + } + /// /// Create BoxConfigBuilder from json file. /// @@ -225,6 +238,17 @@ public BoxConfigBuilder SetTimeout(TimeSpan timeout) return this; } + /// + /// Sets enterprise id. + /// + /// Enteprise id. + /// this BoxConfigBuilder object for chaining + public BoxConfigBuilder SetEnterpriseId(string enterpriseId) + { + EnterpriseId = enterpriseId; + return this; + } + public string ClientId { get; private set; } public string ClientSecret { get; private set; } public string EnterpriseId { get; private set; } diff --git a/Box.V2/Config/Constants.cs b/Box.V2/Config/Constants.cs index 9c7b4e396..aa050f203 100644 --- a/Box.V2/Config/Constants.cs +++ b/Box.V2/Config/Constants.cs @@ -160,6 +160,8 @@ public static class Constants public const string AuthHeaderKey = "Authorization"; public const string V1AuthString = "BoxAuth api_key={0}&auth_token={1}"; public const string V2AuthString = "Bearer {0}"; + public const string BearerTokenType = "bearer"; + public const int AccessTokenExpirationTime = 3600; // seconds /*** Return types ***/ public const string TypeFile = "file"; @@ -234,6 +236,9 @@ public static class RequestParameters public const string Digest = "Digest"; + public const string SubjectType = "box_subject_type"; + public const string SubjectId = "box_subject_id"; + /*** Values ***/ public const string RefreshToken = "refresh_token"; public const string AuthorizationCode = "authorization_code"; @@ -261,6 +266,10 @@ public static class RequestParameters public const string IfMatch = "If-Match"; + public const string ClientCredentials = "client_credentials"; + public const string UserSubType = "user"; + public const string EnterpriseSubType = "enterprise"; + /*** Values for specifically representations endpoint ***/ public const string XRepHints = "x-rep-hints"; public const string SetContentDispositionType = "set_content_disposition_type";