From d1ddb646a84caf19ccef2f5f75772521c2d661cf Mon Sep 17 00:00:00 2001 From: ckoegel Date: Wed, 4 Feb 2026 17:27:32 -0500 Subject: [PATCH 1/5] SWI-9214 Add OAuth Support --- Bandwidth.Iris/Client.cs | 167 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 161 insertions(+), 6 deletions(-) diff --git a/Bandwidth.Iris/Client.cs b/Bandwidth.Iris/Client.cs index b242de9..22811b2 100644 --- a/Bandwidth.Iris/Client.cs +++ b/Bandwidth.Iris/Client.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Linq; using System.Net; @@ -11,6 +10,8 @@ using System.Threading.Tasks; using System.Xml.Linq; using System.Xml.Serialization; +using Newtonsoft.Json.Linq; +using System.Threading; namespace Bandwidth.Iris { @@ -23,12 +24,36 @@ public sealed class Client private readonly string _apiEndpoint; private readonly string _apiVersion; private readonly string _accountPath; + private string _accessToken; + private DateTimeOffset _accessTokenExpiration; + private readonly string _clientId; + private readonly string _clientSecret; + private readonly TimeSpan _tokenRefreshSkew = TimeSpan.FromMinutes(1); + private readonly SemaphoreSlim _tokenLock = new SemaphoreSlim(1, 1); public static Client GetInstance(string accountId, string userName, string password, string apiEndpoint = "https://dashboard.bandwidth.com", string apiVersion = "v1.0") { return new Client(accountId, userName, password, apiEndpoint, apiVersion); } + // Factory: Access token with explicit expiration (absolute time) + public static Client GetInstanceWithAccessToken(string accountId, string accessToken, DateTimeOffset? accessTokenExpiration = null, string apiEndpoint = "https://dashboard.bandwidth.com", string apiVersion = "v1.0") + { + return new Client(accountId, apiEndpoint, apiVersion, accessToken, accessTokenExpiration); + } + + // Factory: Use client credentials to acquire/refresh tokens + public static Client GetInstanceWithClientCredentials(string accountId, string clientId, string clientSecret, string apiEndpoint = "https://dashboard.bandwidth.com", string apiVersion = "v1.0") + { + return new Client(accountId, clientId, clientSecret, apiEndpoint, apiVersion, null, null); + } + + // Factory: Use client credentials to acquire/refresh tokens, with initial access token and expiration + public static Client GetInstanceWithClientCredentialsAndAccessToken(string accountId, string clientId, string clientSecret, string accessToken, DateTimeOffset? accessTokenExpiration = null, string apiEndpoint = "https://dashboard.bandwidth.com", string apiVersion = "v1.0") + { + return new Client(accountId, clientId, clientSecret, apiEndpoint, apiVersion, accessToken, accessTokenExpiration); + } + #if !PCL public const string BandwidthApiAccountId = "BANDWIDTH_API_ACCOUNT_ID"; @@ -52,8 +77,6 @@ public static Client GetInstance() private Client(string accountId, string userName, string password, string apiEndpoint, string apiVersion) { if (accountId == null) throw new ArgumentNullException("accountId"); - if (userName == null) throw new ArgumentNullException("userName"); - if (password == null) throw new ArgumentNullException("password"); if (apiEndpoint == null) throw new ArgumentNullException("apiEndpoint"); if (apiVersion == null) throw new ArgumentNullException("apiVersion"); _userName = userName; @@ -61,25 +84,151 @@ private Client(string accountId, string userName, string password, string apiEnd _apiEndpoint = apiEndpoint; _apiVersion = apiVersion; _accountPath = string.Format("accounts/{0}", accountId); + _clientId = null; + _clientSecret = null; + } + + // Constructor for access-token + private Client(string accountId, string apiEndpoint, string apiVersion, string accessToken, DateTimeOffset? accessTokenExpiration = null) + { + if (accountId == null) throw new ArgumentNullException("accountId"); + if (accessToken == null) throw new ArgumentNullException("accessToken"); + if (apiEndpoint == null) throw new ArgumentNullException("apiEndpoint"); + if (apiVersion == null) throw new ArgumentNullException("apiVersion"); + _userName = null; + _password = null; + _apiEndpoint = apiEndpoint; + _apiVersion = apiVersion; + _accountPath = string.Format("accounts/{0}", accountId); + _accessToken = accessToken; + if (accessTokenExpiration.HasValue) + { + _accessTokenExpiration = accessTokenExpiration.Value; + } + else + { + _accessTokenExpiration = DateTimeOffset.UtcNow.AddMinutes(60); + } + _clientId = null; + _clientSecret = null; + } + + // Constructor for client-credentials flow with optional initial access token + private Client(string accountId, string clientId, string clientSecret, string apiEndpoint, string apiVersion, string accessToken = null, DateTimeOffset? accessTokenExpiration = null) + { + if (accountId == null) throw new ArgumentNullException("accountId"); + if (clientId == null) throw new ArgumentNullException("clientId"); + if (clientSecret == null) throw new ArgumentNullException("clientSecret"); + if (apiEndpoint == null) throw new ArgumentNullException("apiEndpoint"); + if (apiVersion == null) throw new ArgumentNullException("apiVersion"); + _userName = null; + _password = null; + _apiEndpoint = apiEndpoint; + _apiVersion = apiVersion; + _accountPath = string.Format("accounts/{0}", accountId); + _clientId = clientId; + _clientSecret = clientSecret; + _accessToken = accessToken; + if (accessTokenExpiration.HasValue) + { + _accessTokenExpiration = accessTokenExpiration.Value; + } + else + { + _accessTokenExpiration = DateTimeOffset.UtcNow.AddMinutes(60); + } } private HttpClient CreateHttpClient() { var url = new UriBuilder(_apiEndpoint) { Path = string.Format("/{0}/", _apiVersion) }; var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }) { BaseAddress = url.Uri }; - client.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Basic", - Convert.ToBase64String(Encoding.UTF8.GetBytes(string.Format("{0}:{1}", _userName, _password)))); + if (!string.IsNullOrEmpty(_accessToken)) + { + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken); + } + else if (!string.IsNullOrEmpty(_userName) && !string.IsNullOrEmpty(_password)) + { + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes(string.Format("{0}:{1}", _userName, _password)))); + } client.DefaultRequestHeaders.Add("User-Agent", USER_AGENT); client.DefaultRequestHeaders.Add("Accept", "application/xml"); return client; } + // Use valid access token, or acquire new one using client credentials + private async Task EnsureAccessTokenAsync() + { + if (!string.IsNullOrEmpty(_accessToken) && + _accessTokenExpiration > DateTimeOffset.UtcNow.Add(_tokenRefreshSkew)) + { + return; + } + + if (string.IsNullOrEmpty(_clientId) || string.IsNullOrEmpty(_clientSecret)) + { + return; + } + + await _tokenLock.WaitAsync(); + try + { + using (var http = new HttpClient()) + { + var form = new List> + { + new KeyValuePair("grant_type", "client_credentials") + }; + + var tokenEndpoint = new UriBuilder("https://api.bandwidth.com/api/v1/oauth2/token").Uri; + var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint) + { + Content = new FormUrlEncodedContent(form) + }; + + var authString = Convert.ToBase64String(Encoding.UTF8.GetBytes(string.Format("{0}:{1}", _clientId, _clientSecret))); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", authString); + + using (var response = await http.SendAsync(request)) + { + if (!response.IsSuccessStatusCode) + { + + return; + } + var json = await response.Content.ReadAsStringAsync(); + var obj = JObject.Parse(json); + var token = (string)obj["access_token"]; + var expiresIn = (int?)obj["expires_in"]; + if (!string.IsNullOrEmpty(token)) + { + _accessToken = token; + if (expiresIn.HasValue) + { + _accessTokenExpiration = DateTimeOffset.UtcNow.AddSeconds(expiresIn.Value); + } + else + { + _accessTokenExpiration = DateTimeOffset.UtcNow.AddMinutes(60); + } + } + } + } + } + finally + { + _tokenLock.Release(); + } + } + #region Base Http methods internal async Task MakeGetRequest(string path, IDictionary query = null, string id = null, bool disposeResponse = false) { + await EnsureAccessTokenAsync(); var urlPath = FixPath(path); if (id != null) { @@ -161,6 +310,7 @@ public async Task MakeGetRequest(string path, IDictionary MakePostRequest(string path, object data, bool disposeResponse = false) { + await EnsureAccessTokenAsync(); var serializer = new XmlSerializer(data.GetType()); using (var writer = new Utf8StringWriter()) { @@ -188,6 +338,7 @@ public async Task MakePostRequest(string path, object data, public async Task MakePatchRequest(string path, object data, bool disposeResponse = false) { + await EnsureAccessTokenAsync(); var serializer = new XmlSerializer(data.GetType()); using (var writer = new Utf8StringWriter()) { @@ -228,6 +379,7 @@ public async Task MakePatchRequest(string path, object data internal async Task MakePutRequest(string path, object data, bool disposeResponse = false) { + await EnsureAccessTokenAsync(); using (var writer = new Utf8StringWriter()) { @@ -285,6 +437,7 @@ internal async Task SendData(string path, byte[] buffer, st private async Task SendFileContent(string path, string mediaType, bool disposeResponse, HttpContent content, string method) { + await EnsureAccessTokenAsync(); if (mediaType != null) { content.Headers.ContentType = new MediaTypeHeaderValue(mediaType); @@ -367,6 +520,7 @@ internal async Task MakeDeleteRequest(string path, string id = null) { path = path + "/" + id; } + await EnsureAccessTokenAsync(); using (var client = CreateHttpClient()) using (var response = await client.DeleteAsync(FixPath(path))) { @@ -380,6 +534,7 @@ internal async Task MakeDeleteRequestWithResponse(string pa { path = path + "/" + id; } + await EnsureAccessTokenAsync(); using (var client = CreateHttpClient()) using (var response = await client.DeleteAsync(FixPath(path))) { From f8eb51b62c9b8947a4fe3e99a853faf2847ef8f0 Mon Sep 17 00:00:00 2001 From: ckoegel Date: Wed, 4 Feb 2026 17:27:38 -0500 Subject: [PATCH 2/5] add tests --- Bandwidth.Iris.Tests/ClientAuthTests.cs | 146 ++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 Bandwidth.Iris.Tests/ClientAuthTests.cs diff --git a/Bandwidth.Iris.Tests/ClientAuthTests.cs b/Bandwidth.Iris.Tests/ClientAuthTests.cs new file mode 100644 index 0000000..c33b714 --- /dev/null +++ b/Bandwidth.Iris.Tests/ClientAuthTests.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Net.Http; +using System.Threading.Tasks; +using Xunit; + +namespace Bandwidth.Iris.Tests +{ + public class ClientAuthTests + { + [Fact] + public async Task UsesBearerHeaderWhenAccessTokenProvided() + { + var token = "abc123"; + using (var server = new HttpServer(new RequestHandler + { + EstimatedMethod = "GET", + EstimatedPathAndQuery = "/v1.0/test", + EstimatedHeaders = new Dictionary + { + {"Authorization", "Bearer " + token} + } + })) + { + var client = Client.GetInstanceWithAccessToken(Helper.AccountId, token, apiEndpoint: "http://localhost:3001/"); + await client.MakeGetRequest("test", null, null, true); + if (server.Error != null) throw server.Error; + } + } + + [Fact] + public async Task UsesBearerHeaderWhenValidEvenWithClientCredentials() + { + var clientId = "FakeClientId"; + var clientSecret = "FakeClientSecret"; + var token = "validToken"; + var tokenExpiration = DateTimeOffset.UtcNow.AddHours(1); + var tokenRequestAuthString = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes(clientId + ":" + clientSecret)); + + using (var apiServer = new HttpServer(new RequestHandler + { + EstimatedMethod = "GET", + EstimatedPathAndQuery = "/v1.0/test", + EstimatedHeaders = new Dictionary + { + {"Authorization", "Bearer " + token} + } + })) + { + var client = Client.GetInstanceWithClientCredentialsAndAccessToken(Helper.AccountId, clientId, clientSecret, token, tokenExpiration, "http://localhost:3001/"); + await client.MakeGetRequest("test", null, null, true); + if (apiServer.Error != null) throw apiServer.Error; + } + + + } + + [Fact] + public async Task RefreshesTokenWithinOneMinuteSkew() + { + var clientId = "FakeClientId"; + var clientSecret = "FakeClientSecret"; + var tokenRequestAuthString = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes(clientId + ":" + clientSecret)); + // Token endpoint server on port 3002: first call returns tk1 (short expiry), second returns tk2 + using (var tokenServer = new HttpServer(new[] { + new RequestHandler + { + EstimatedMethod = "POST", + EstimatedPathAndQuery = "/", + EstimatedHeaders = new Dictionary + { + {"Authorization", tokenRequestAuthString} + }, + ContentToSend = new StringContent("{\"access_token\":\"tk1\",\"expires_in\":30}", Encoding.UTF8, "application/json") + }, + new RequestHandler + { + EstimatedMethod = "POST", + EstimatedPathAndQuery = "/", + EstimatedHeaders = new Dictionary + { + {"Authorization", tokenRequestAuthString} + }, + ContentToSend = new StringContent("{\"access_token\":\"tk2\",\"expires_in\":3600}", Encoding.UTF8, "application/json") + } + }, prefix: "http://localhost:3002/")) + { + // API server expects first GET with tk1, second GET with tk2 + using (var apiServer = new HttpServer(new[] { + new RequestHandler + { + EstimatedMethod = "GET", + EstimatedPathAndQuery = "/v1.0/test", + EstimatedHeaders = new Dictionary + { + {"Authorization", "Bearer tk1"} + } + }, + new RequestHandler + { + EstimatedMethod = "GET", + EstimatedPathAndQuery = "/v1.0/test", + EstimatedHeaders = new Dictionary + { + {"Authorization", "Bearer tk2"} + } + } + })) + { + var client = Client.GetInstanceWithClientCredentials(Helper.AccountId, clientId, clientSecret, "http://localhost:3002/"); + + await client.MakeGetRequest("test", null, null, true); + if (apiServer.Error != null) throw apiServer.Error; + + // Second call should see short expiry (<= 1 minute) and refresh to tk2 + await Task.Delay(100); // small delay to avoid race conditions + await client.MakeGetRequest("test", null, null, true); + if (apiServer.Error != null) throw apiServer.Error; + } + } + } + + [Fact] + public async Task UsesBasicAuthWhenNoOauthSupplied() + { + var userName = "user1"; + var password = "pass123"; + var basicAuthString = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes(userName + ":" + password)); + using (var server = new HttpServer(new RequestHandler + { + EstimatedMethod = "GET", + EstimatedPathAndQuery = "/v1.0/test", + EstimatedHeaders = new Dictionary + { + {"Authorization", basicAuthString} + } + })) + { + var client = Client.GetInstance(Helper.AccountId, userName, password, apiEndpoint: "http://localhost:3001/"); + await client.MakeGetRequest("test", null, null, true); + if (server.Error != null) throw server.Error; + } + } + } +} From 82d25c0e57e79e4b4394196c0393e0969272fe49 Mon Sep 17 00:00:00 2001 From: ckoegel Date: Wed, 4 Feb 2026 17:27:44 -0500 Subject: [PATCH 3/5] update workflow files --- .github/workflows/deploy.yaml | 4 ++-- .github/workflows/test.yml | 17 +++++++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index cf9675b..959f1fa 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -13,11 +13,11 @@ jobs: BW_PROJECT_TEST_NAME: Bandwidth.Iris.Tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set release version run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/v}" >> $GITHUB_ENV - - uses: actions/setup-dotnet@v3 + - uses: actions/setup-dotnet@v5 with: dotnet-version: "6.0" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 02a1f5b..e77f469 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,10 +13,10 @@ jobs: dotnet: [6.0.x, 7.0.x] steps: - name: Checkout repo - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: ${{ matrix.dotnet }} @@ -26,10 +26,15 @@ jobs: DOTNET: ${{ matrix.dotnet }} run: dotnet test Bandwidth.Iris.Tests - - name: Notify Slack of failures - uses: Bandwidth/build-notify-slack-action@v1.0.0 - if: failure() && !github.event.pull_request.draft + notify_for_failures: + name: Notify for Failures + needs: [test] + if: failure() + runs-on: ubuntu-latest + steps: + - name: Notify Slack of Failures + uses: Bandwidth/build-notify-slack-action@v2 with: - job-status: ${{ job.status }} + job-status: failure slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} slack-channel: ${{ secrets.SLACK_CHANNEL }} From 13f7476773681ebf32c5ec5f2a689821a8c7f5c1 Mon Sep 17 00:00:00 2001 From: ckoegel Date: Wed, 4 Feb 2026 17:32:58 -0500 Subject: [PATCH 4/5] yaml -> yml --- .github/workflows/{deploy.yaml => deploy.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{deploy.yaml => deploy.yml} (100%) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yml similarity index 100% rename from .github/workflows/deploy.yaml rename to .github/workflows/deploy.yml From 492c01b68b2400280fe594980fa5e44d0e2a5a51 Mon Sep 17 00:00:00 2001 From: ckoegel Date: Thu, 5 Feb 2026 11:08:10 -0500 Subject: [PATCH 5/5] update readme --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 95822ee..4f929e9 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,13 @@ Or install Bandwidth.Iris via UI in Visual Studio * Configure the Client ```csharp +// Auth using Bearer token +var client = Client.GetInstanceWithAccessToken("accountId", "accessToken"); + +// Auth using client credentials +var client = Client.GetInstanceWithClientCredentials("accountId", "clientId", "clientSecret"); + +// Auth using basic auth var client = Client.GetInstance("accountId", "username", "password", "apiEndpoint") //Or //Uses the System Environment Variables as detailed below @@ -1213,4 +1220,4 @@ var result = await TnOptions.List(client, new Dictionary { {"status", "9199918388" } }); -``` \ No newline at end of file +```