Skip to content

Commit

Permalink
Add Boxer Client (#35)
Browse files Browse the repository at this point in the history
  • Loading branch information
jrbentzon authored Feb 7, 2024
1 parent 1a3ee82 commit bd2455c
Show file tree
Hide file tree
Showing 19 changed files with 432 additions and 5 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,4 @@ jobs:
uses: SneaksAndData/github-actions/[email protected]
with:
major_v: 0
minor_v: 0
minor_v: 1
2 changes: 1 addition & 1 deletion src/SnD.ApiClient.Azure/SnD.ApiClient.Azure.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<TargetFramework>net6.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
Expand Down
65 changes: 65 additions & 0 deletions src/SnD.ApiClient/Boxer/Base/IBoxerClaimsClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using SnD.ApiClient.Boxer.Exceptions;
using SnD.ApiClient.Boxer.Models;

namespace SnD.ApiClient.Boxer.Base;

public interface IBoxerClaimsClient
{
/// <summary>
/// Create a jwt-user registration in Boxer for a given user id and provider
/// If user already exists, the method not do anything and return true
/// </summary>
/// <param name="userId"></param>
/// <param name="provider"></param>
/// <param name="cancellationToken"></param>
/// <returns>true if success or if user already exists</returns>
/// <exception cref="HttpRequestException">Throws if the request to Boxer fails</exception>
public Task<bool> CreateUserAsync(string userId, string provider, CancellationToken cancellationToken);

/// <summary>
/// Deletes a user from Boxer by user id and provider (jwt-user registration) and all its claims.
/// </summary>
/// <param name="userId"></param>
/// <param name="provider"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <exception cref="UserNotFoundException">Throws if the user is not found under that identity provider</exception>
/// <exception cref="HttpRequestException">Throws if the request to Boxer fails</exception>
public Task<bool> DisassociateUserAsync(string userId, string provider, CancellationToken cancellationToken);

/// <summary>
/// Get claims by user id and provider
/// </summary>
/// <param name="userId">User principal name (UPN) in Boxer</param>
/// <param name="provider">Identity provider (IDP) in Boxer</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Enumerator of object <see cref="BoxerJwtClaim"/> for the user</returns>
/// <exception cref="UserNotFoundException">Throws if the user is not found under that identity provider</exception>
/// <exception cref="HttpRequestException">Throws if the request to Boxer fails</exception>
public Task<IEnumerable<BoxerJwtClaim>> GetUserClaimsAsync(string userId, string provider, CancellationToken cancellationToken);

/// <summary>
/// Set (Update/Edit) claims for user id and provider
/// </summary>
/// <param name="userId">User principal name (UPN) in Boxer</param>
/// <param name="provider">Identity provider (IDP) in Boxer</param>
/// <param name="claims">Claims to set in the form of an enumerator of type <see cref="BoxerJwtClaim"/></param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>True if the operation was successful, false otherwise</returns>
/// <exception cref="UserNotFoundException">Throws if the user is not found under that identity provider</exception>
/// <exception cref="HttpRequestException">Throws if the request to Boxer fails</exception>
public Task<bool> PatchUserClaimsAsync(string userId, string provider, IEnumerable<BoxerJwtClaim> claims, CancellationToken cancellationToken);

/// <summary>
/// Delete claims for user id and provider
/// Note: The method will match claims by user, identity provider and path. It will not match by api methods.
/// </summary>
/// <param name="userId">User principal name (UPN) in Boxer</param>
/// <param name="provider">Identity provider (IDP) in Boxer</param>
/// <param name="claims">Claims to delete in the form of an enumerator of type <see cref="BoxerJwtClaim"/></param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>true on success</returns>
/// <exception cref="UserNotFoundException">Throws if the user is not found under that identity provider</exception>
/// <exception cref="HttpRequestException">Throws if the request to Boxer fails</exception>
public Task<bool> DeleteUserClaimsAsync(string userId, string provider, IEnumerable<BoxerJwtClaim> claims, CancellationToken cancellationToken);
}
2 changes: 1 addition & 1 deletion src/SnD.ApiClient/Boxer/Base/IJwtTokenExchangeProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ public interface IJwtTokenExchangeProvider
/// <returns>Boxer JWT value</returns>
/// <exception cref="HttpRequestException">Raised on unsuccessful http request</exception>
public Task<string> GetTokenAsync(bool refresh, CancellationToken cancellationToken);
}
}
93 changes: 93 additions & 0 deletions src/SnD.ApiClient/Boxer/BoxerClaimsClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SnD.ApiClient.Base;
using SnD.ApiClient.Boxer.Base;
using SnD.ApiClient.Boxer.Exceptions;
using SnD.ApiClient.Boxer.Extensions;
using SnD.ApiClient.Boxer.Models;
using SnD.ApiClient.Config;

namespace SnD.ApiClient.Boxer;

public class BoxerClaimsClient : SndApiClient, IBoxerClaimsClient
{
private readonly Uri claimsUri;

public BoxerClaimsClient
(IOptions<BoxerClaimsClientOptions> boxerClientOptions, HttpClient httpClient,
IJwtTokenExchangeProvider boxerConnector, ILogger<BoxerClaimsClient> logger) : base(httpClient, boxerConnector,
logger)
{
claimsUri = new Uri(boxerClientOptions.Value.BaseUri
?? throw new ArgumentNullException(nameof(BoxerClaimsClientOptions.BaseUri)));
}

public async Task<bool> CreateUserAsync(string userId, string provider, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var requestUri = new Uri(claimsUri, new Uri($"claim/{provider}/{userId}", UriKind.Relative));
var request = new HttpRequestMessage(HttpMethod.Post, requestUri);
request.Content = new StringContent("{}", Encoding.UTF8, "application/json");
var response = await SendAuthenticatedRequestAsync(request, cancellationToken);
return response.EnsureSuccessStatusCode().IsSuccessStatusCode;
}

public async Task<bool> DisassociateUserAsync(string userId, string provider, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var requestUri = new Uri(claimsUri, new Uri($"claim/{provider}/{userId}", UriKind.Relative));
var request = new HttpRequestMessage(HttpMethod.Delete, requestUri);
request.Content = new StringContent("{}", Encoding.UTF8, "application/json");
var response = await SendAuthenticatedRequestAsync(request, cancellationToken);
return response.StatusCode switch
{
HttpStatusCode.NotFound => throw new UserNotFoundException(),
_ => response.EnsureSuccessStatusCode().IsSuccessStatusCode
};
}

public async Task<IEnumerable<BoxerJwtClaim>> GetUserClaimsAsync(string userId, string provider,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var requestUri = new Uri(claimsUri, new Uri($"claim/{provider}/{userId}", UriKind.Relative));
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
var response = await SendAuthenticatedRequestAsync(request, cancellationToken);
return await response.ExtractClaimsAsync();
}

public async Task<bool> PatchUserClaimsAsync(string userId, string provider, IEnumerable<BoxerJwtClaim> claims,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var requestUri = new Uri(claimsUri, new Uri($"claim/{provider}/{userId}", UriKind.Relative));
var requestBody = BoxerClaimsApiPatchBody.CreateInsertOperation(claims);
var request = new HttpRequestMessage(HttpMethod.Patch, requestUri);
request.Content = new StringContent(JsonSerializer.Serialize(requestBody), Encoding.UTF8, "application/json");
var response = await SendAuthenticatedRequestAsync(request, cancellationToken);
return response.StatusCode switch
{
HttpStatusCode.NotFound => throw new UserNotFoundException(),
_ => response.EnsureSuccessStatusCode().IsSuccessStatusCode
};
}

public async Task<bool> DeleteUserClaimsAsync(string userId, string provider, IEnumerable<BoxerJwtClaim> claims,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var requestUri = new Uri(claimsUri, new Uri($"claim/{provider}/{userId}", UriKind.Relative));
var requestBody = BoxerClaimsApiPatchBody.CreateDeleteOperation(claims);
var request = new HttpRequestMessage(HttpMethod.Patch, requestUri);
request.Content = new StringContent(JsonSerializer.Serialize(requestBody), Encoding.UTF8, "application/json");
var response = await SendAuthenticatedRequestAsync(request, cancellationToken);
return response.StatusCode switch
{
HttpStatusCode.NotFound => throw new UserNotFoundException(),
_ => response.EnsureSuccessStatusCode().IsSuccessStatusCode
};
}
}
17 changes: 17 additions & 0 deletions src/SnD.ApiClient/Boxer/Exceptions/UserNotFoundException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.Diagnostics.CodeAnalysis;

namespace SnD.ApiClient.Boxer.Exceptions;

/// <summary>
/// Exception thrown when a user is not found in Boxer, e.g., when trying to add claims to a user that does not exist
/// </summary>
[ExcludeFromCodeCoverage]
public class UserNotFoundException : Exception
{
/// <summary>
/// Create a new instance of <see cref="UserNotFoundException"/>
/// </summary>
public UserNotFoundException() : base("Requested user not found in Boxer")
{
}
}
19 changes: 19 additions & 0 deletions src/SnD.ApiClient/Boxer/Extensions/BoxerJwtClaimExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System.Net;
using System.Text.Json;
using SnD.ApiClient.Boxer.Exceptions;
using SnD.ApiClient.Boxer.Models;

namespace SnD.ApiClient.Boxer.Extensions;

public static class BoxerJwtClaimExtensions
{
public static async Task<IEnumerable<BoxerJwtClaim>> ExtractClaimsAsync(this HttpResponseMessage response)
{
return response.StatusCode switch
{
HttpStatusCode.NotFound => throw new UserNotFoundException(),
_ => JsonSerializer.Deserialize<GetUserClaimsResponse>(await response.EnsureSuccessStatusCode().Content
.ReadAsStringAsync()).Claims.Select(c => new BoxerJwtClaim(c.First().Key, c.First().Value))
};
}
}
32 changes: 32 additions & 0 deletions src/SnD.ApiClient/Boxer/Extensions/StringHttpMethodExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System.Collections.Immutable;
using System.Text.RegularExpressions;

namespace SnD.ApiClient.Boxer.Extensions;

public static class StringHttpMethodExtensions
{
/// <summary>
/// Convert a string to a collection of HttpMethod that matches (by regex) the string
/// </summary>
/// <param name="httpMethods"></param>
/// <returns></returns>
public static IEnumerable<HttpMethod> ToBoxerHttpMethods(this string httpMethods)
{
if (string.IsNullOrWhiteSpace(httpMethods))
return Enumerable.Empty<HttpMethod>();
var regex = new Regex(httpMethods);
return new[] { HttpMethod.Get, HttpMethod.Post, HttpMethod.Put, HttpMethod.Delete, HttpMethod.Patch }
.Where(m => regex.IsMatch(m.Method));
}

/// <summary>
/// Convert a collection of HttpMethod to a regex string that matches the collection
/// </summary>
/// <param name="httpMethods"></param>
/// <returns></returns>
public static string ToRegexString(this IEnumerable<HttpMethod> httpMethods)
{
var methods = httpMethods.Select(m=>m.Method).ToImmutableSortedSet();
return methods.IsEmpty ? string.Empty : $"^({string.Join("|", methods)})$";
}
}
33 changes: 33 additions & 0 deletions src/SnD.ApiClient/Boxer/Models/BoxerClaimsApiPatchBody.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;


namespace SnD.ApiClient.Boxer.Models;

[ExcludeFromCodeCoverage]
internal class BoxerClaimsApiPatchBody
{
private BoxerClaimsApiPatchBody()
{
}

public static BoxerClaimsApiPatchBody CreateInsertOperation(IEnumerable<BoxerJwtClaim> claims) =>
new()
{
Operation = "Insert",
Claims = claims.ToDictionary(c => c.Type, c => c.Value)
};

public static BoxerClaimsApiPatchBody CreateDeleteOperation(IEnumerable<BoxerJwtClaim> claims) =>
new()
{
Operation = "Delete",
Claims = claims.ToDictionary(c => c.Type, c => c.Value)
};

[JsonPropertyName("operation")]
public string Operation { get; private set; }

[JsonPropertyName("claims")]
public Dictionary<string, string> Claims { get; private set; } = new();
}
48 changes: 48 additions & 0 deletions src/SnD.ApiClient/Boxer/Models/BoxerJwtClaim.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System.Diagnostics.CodeAnalysis;
using System.Security.Claims;
using System.Text.Json;
using SnD.ApiClient.Boxer.Extensions;

namespace SnD.ApiClient.Boxer.Models;

[ExcludeFromCodeCoverage]
public class BoxerJwtClaim : Claim
{
internal BoxerJwtClaim(string type, string value) : base(type, value)
{
ApiMethods = value.ToBoxerHttpMethods().ToHashSet();
Path = type;
}

/// <summary>
/// Static method to convert a Boxer API Claims response to a collection of BoxerJwtClaim objects
/// </summary>
/// <param name="response"></param>
/// <returns></returns>
public static IEnumerable<BoxerJwtClaim> FromBoxerClaimsApiResponse(string response)
{
return JsonSerializer.Deserialize<GetUserClaimsResponse>(response)
.Claims.Select(c => new BoxerJwtClaim(c.First().Key, c.First().Value));
}

/// <summary>
/// Static constructor to create a BoxerJwtClaim from a path and a collection of ApiMethodElement
/// </summary>
/// <param name="path"></param>
/// <param name="apiMethods"></param>
/// <returns></returns>
public static BoxerJwtClaim Create(string path, HashSet<HttpMethod> apiMethods)
{
return new BoxerJwtClaim(path, apiMethods.ToRegexString());
}

/// <summary>
/// API path
/// </summary>
public readonly string Path;

/// <summary>
/// API methods
/// </summary>
public readonly HashSet<HttpMethod> ApiMethods;
}
18 changes: 18 additions & 0 deletions src/SnD.ApiClient/Boxer/Models/GetUserClaimsResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;

namespace SnD.ApiClient.Boxer.Models;

internal class GetUserClaimsResponse
{
[JsonPropertyName("identityProvider")]
public string IdentityProvider { get; set; }

[JsonPropertyName("userId")]
public string UserId { get; set; }

[JsonPropertyName("claims")]
public List<Dictionary<string, string>> Claims { get; set; }

[JsonPropertyName("billingId")]
public object BillingId { get; set; }
}
9 changes: 9 additions & 0 deletions src/SnD.ApiClient/Config/BoxerClaimsClientOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace SnD.ApiClient.Config;

public class BoxerClaimsClientOptions
{
/// <summary>
/// Base URI of the Boxer Claims API instance
/// </summary>
public string BaseUri { get; set; }
}
10 changes: 9 additions & 1 deletion src/SnD.ApiClient/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,12 @@ public static IServiceCollection AddCrystalClient(this IServiceCollection servic
{
return services.AddSingleton<ICrystalClient, CrystalClient>();
}
}

/// <summary>
/// Add Boxer Claims Client to DI
/// </summary>
public static IServiceCollection AddBoxerClaimsClient(this IServiceCollection services)
{
return services.AddSingleton<IBoxerClaimsClient, BoxerClaimsClient>();
}
}
2 changes: 1 addition & 1 deletion src/SnD.ApiClient/SnD.ApiClient.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<RootNamespace>SnD.ApiClient</RootNamespace>
Expand Down
Loading

0 comments on commit bd2455c

Please sign in to comment.