Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
  • Loading branch information
waldekmastykarz committed Feb 22, 2024
2 parents b592733 + cb9291c commit 0bd4601
Show file tree
Hide file tree
Showing 5 changed files with 274 additions and 4 deletions.
4 changes: 3 additions & 1 deletion dev-proxy-plugins/MockResponses/CrudApiDefinitionLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ public void LoadApiDefinition()
var apiDefinitionConfig = JsonSerializer.Deserialize<CrudApiConfiguration>(apiDefinitionString);
_configuration.BaseUrl = apiDefinitionConfig?.BaseUrl ?? string.Empty;
_configuration.DataFile = apiDefinitionConfig?.DataFile ?? string.Empty;
_configuration.Auth = apiDefinitionConfig?.Auth ?? CrudApiAuthType.None;
_configuration.EntraAuthConfig = apiDefinitionConfig?.EntraAuthConfig;

IEnumerable<CrudApiAction>? configResponses = apiDefinitionConfig?.Actions;
if (configResponses is not null)
Expand Down Expand Up @@ -66,7 +68,7 @@ public void LoadApiDefinition()
}
catch (Exception ex)
{
_logger.LogError(ex, "An error has occurred while reading {apiFile}:", _configuration.ApiFile);
_logger.LogError(ex, "An error has occurred while reading {apiFile}", _configuration.ApiFile);
}
}

Expand Down
195 changes: 193 additions & 2 deletions dev-proxy-plugins/MockResponses/CrudApiPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Microsoft.Extensions.Logging;
using System.IdentityModel.Tokens.Jwt;
using System.Diagnostics;
using Microsoft.IdentityModel.Tokens;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Protocols;
using System.Security.Claims;

namespace Microsoft.DevProxy.Plugins.MockResponses;

Expand All @@ -26,6 +32,28 @@ public enum CrudApiActionType
Delete
}

public enum CrudApiAuthType
{
None,
Entra
}

public class CrudApiEntraAuth
{
[JsonPropertyName("audience")]
public string Audience { get; set; } = string.Empty;
[JsonPropertyName("issuer")]
public string Issuer { get; set; } = string.Empty;
[JsonPropertyName("scopes")]
public string[] Scopes { get; set; } = Array.Empty<string>();
[JsonPropertyName("roles")]
public string[] Roles { get; set; } = Array.Empty<string>();
[JsonPropertyName("validateLifetime")]
public bool ValidateLifetime { get; set; } = false;
[JsonPropertyName("validateSigningKey")]
public bool ValidateSigningKey { get; set; } = false;
}

public class CrudApiAction
{
[JsonPropertyName("action")]
Expand All @@ -37,6 +65,11 @@ public class CrudApiAction
public string? Method { get; set; }
[JsonPropertyName("query")]
public string Query { get; set; } = string.Empty;
[JsonPropertyName("auth")]
[System.Text.Json.Serialization.JsonConverter(typeof(JsonStringEnumConverter))]
public CrudApiAuthType Auth { get; set; } = CrudApiAuthType.None;
[JsonPropertyName("entraAuthConfig")]
public CrudApiEntraAuth? EntraAuthConfig { get; set; }
}

public class CrudApiConfiguration
Expand All @@ -49,6 +82,11 @@ public class CrudApiConfiguration
public string DataFile { get; set; } = string.Empty;
[JsonPropertyName("actions")]
public IEnumerable<CrudApiAction> Actions { get; set; } = Array.Empty<CrudApiAction>();
[JsonPropertyName("auth")]
[System.Text.Json.Serialization.JsonConverter(typeof(JsonStringEnumConverter))]
public CrudApiAuthType Auth { get; set; } = CrudApiAuthType.None;
[JsonPropertyName("entraAuthConfig")]
public CrudApiEntraAuth? EntraAuthConfig { get; set; }
}

public class CrudApiPlugin : BaseProxyPlugin
Expand All @@ -58,8 +96,9 @@ public class CrudApiPlugin : BaseProxyPlugin
public override string Name => nameof(CrudApiPlugin);
private IProxyConfiguration? _proxyConfiguration;
private JArray? _data;
private OpenIdConnectConfiguration? _openIdConnectConfiguration;

public override void Register(IPluginEvents pluginEvents,
public override async void Register(IPluginEvents pluginEvents,
IProxyContext context,
ISet<UrlToWatch> urlsToWatch,
IConfigurationSection? configSection = null)
Expand All @@ -77,7 +116,29 @@ public override void Register(IPluginEvents pluginEvents,
_loader = new CrudApiDefinitionLoader(_logger!, _configuration);
_loader?.InitApiDefinitionWatcher();

if (_configuration.Auth == CrudApiAuthType.Entra &&
_configuration.EntraAuthConfig is null)
{
_logger?.LogError("Entra auth is enabled but no configuration is provided. API will work anonymously.");
_configuration.Auth = CrudApiAuthType.None;
}

LoadData();
await SetupOpenIdConnectConfiguration();
}

private async Task SetupOpenIdConnectConfiguration()
{
try
{
var retriever = new OpenIdConnectConfigurationRetriever();
var configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>("https://login.microsoftonline.com/organizations/v2.0/.well-known/openid-configuration", retriever);
_openIdConnectConfiguration = await configurationManager.GetConfigurationAsync();
}
catch (Exception ex)
{
_logger?.LogError(ex, "An error has occurred while loading OpenIdConnectConfiguration");
}
}

private void LoadData()
Expand All @@ -96,7 +157,7 @@ private void LoadData()
}
catch (Exception ex)
{
_logger?.LogError(ex, "An error has occured while reading {configFile}", _configuration.DataFile);
_logger?.LogError(ex, "An error has occurred while reading {configFile}", _configuration.DataFile);
}
}

Expand All @@ -107,9 +168,23 @@ protected virtual Task OnRequest(object? sender, ProxyRequestArgs e)

if (_urlsToWatch is not null && e.ShouldExecute(_urlsToWatch))
{
if (!AuthorizeRequest(e))
{
SendUnauthorizedResponse(e.Session);
state.HasBeenSet = true;
return Task.CompletedTask;
}

var actionAndParams = GetMatchingActionHandler(request);
if (actionAndParams is not null)
{
if (!AuthorizeRequest(e, actionAndParams.Item2))
{
SendUnauthorizedResponse(e.Session);
state.HasBeenSet = true;
return Task.CompletedTask;
}

actionAndParams.Item1(e.Session, actionAndParams.Item2, actionAndParams.Item3);
state.HasBeenSet = true;
}
Expand All @@ -118,6 +193,122 @@ protected virtual Task OnRequest(object? sender, ProxyRequestArgs e)
return Task.CompletedTask;
}

private bool AuthorizeRequest(ProxyRequestArgs e, CrudApiAction? action = null)
{
var authType = action is null ? _configuration.Auth : action.Auth;
var authConfig = action is null ? _configuration.EntraAuthConfig : action.EntraAuthConfig;

if (authType == CrudApiAuthType.None)
{
if (action is null)
{
_logger?.LogDebug("No auth is required for this API.");
}
return true;
}

Debug.Assert(authConfig is not null, "EntraAuthConfig is null when auth is required.");

var token = e.Session.HttpClient.Request.Headers.FirstOrDefault(h => h.Name.Equals("Authorization", StringComparison.OrdinalIgnoreCase))?.Value;
// is there a token
if (string.IsNullOrEmpty(token))
{
_logger?.LogRequest(["401 Unauthorized", "No token found on the request."], MessageType.Failed, new LoggingContext(e.Session));
return false;
}

// does the token has a valid format
var tokenHeaderParts = token.Split(' ');
if (tokenHeaderParts.Length != 2 || tokenHeaderParts[0] != "Bearer")
{
_logger?.LogRequest(["401 Unauthorized", "The specified token is not a valid Bearer token."], MessageType.Failed, new LoggingContext(e.Session));
return false;
}

var handler = new JwtSecurityTokenHandler();
var validationParameters = new TokenValidationParameters
{
IssuerSigningKeys = _openIdConnectConfiguration?.SigningKeys,
ValidateIssuer = !string.IsNullOrEmpty(authConfig.Issuer),
ValidIssuer = authConfig.Issuer,
ValidateAudience = !string.IsNullOrEmpty(authConfig.Audience),
ValidAudience = authConfig.Audience,
ValidateLifetime = authConfig.ValidateLifetime,
ValidateIssuerSigningKey = authConfig.ValidateSigningKey
};
if (!authConfig.ValidateSigningKey)
{
// suppress token validation
validationParameters.SignatureValidator = delegate (string token, TokenValidationParameters parameters)
{
var jwt = new JwtSecurityToken(token);
return jwt;
};
}
SecurityToken validatedToken;
try
{
var claimsPrincipal = handler.ValidateToken(tokenHeaderParts[1], validationParameters, out validatedToken);

// does the token has valid roles/scopes
if (authConfig.Roles.Any())
{
var rolesFromTheToken = string.Join(' ', claimsPrincipal.Claims
.Where(c => c.Type == ClaimTypes.Role)
.Select(c => c.Value));

if (!authConfig.Roles.Any(r => HasPermission(r, rolesFromTheToken)))
{
var rolesRequired = string.Join(", ", authConfig.Roles);

_logger?.LogRequest(["401 Unauthorized", $"The specified token does not have the necessary role(s). Required one of: {rolesRequired}, found: {rolesFromTheToken}"], MessageType.Failed, new LoggingContext(e.Session));
return false;
}

return true;
}
if (authConfig.Scopes.Any())
{
var scopesFromTheToken = string.Join(' ', claimsPrincipal.Claims
.Where(c => c.Type == "http://schemas.microsoft.com/identity/claims/scope")
.Select(c => c.Value));

if (!authConfig.Scopes.Any(s => HasPermission(s, scopesFromTheToken)))
{
var scopesRequired = string.Join(", ", authConfig.Scopes);

_logger?.LogRequest(["401 Unauthorized", $"The specified token does not have the necessary scope(s). Required one of: {scopesRequired}, found: {scopesFromTheToken}"], MessageType.Failed, new LoggingContext(e.Session));
return false;
}

return true;
}
}
catch (Exception ex)
{
_logger?.LogRequest(["401 Unauthorized", $"The specified token is not valid: {ex.Message}"], MessageType.Failed, new LoggingContext(e.Session));
return false;
}

return true;
}

private bool HasPermission(string permission, string permissionString)
{
if (string.IsNullOrEmpty(permissionString))
{
return false;
}

var permissions = permissionString.Split(' ');
return permissions.Contains(permission, StringComparer.OrdinalIgnoreCase);
}

private void SendUnauthorizedResponse(SessionEventArgs e)
{
SendJsonResponse("{\"error\":{\"message\":\"Unauthorized\"}}", HttpStatusCode.Unauthorized, e);
}

private void SendNotFoundResponse(SessionEventArgs e)
{
SendJsonResponse("{\"error\":{\"message\":\"Not found\"}}", HttpStatusCode.NotFound, e);
Expand Down
4 changes: 4 additions & 0 deletions dev-proxy-plugins/dev-proxy-plugins.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="7.3.1">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.OpenApi" Version="1.6.13">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
Expand Down
1 change: 1 addition & 0 deletions dev-proxy/dev-proxy.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="7.3.1" />
<PackageReference Include="Microsoft.OpenApi.Readers" Version="1.6.13" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
Expand Down
74 changes: 73 additions & 1 deletion schemas/v0.15.0/crudapiplugin.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"type": "object",
"properties": {
"$schema": {
"type":"string"
"type": "string"
},
"baseUrl": {
"type": "string"
Expand Down Expand Up @@ -45,13 +45,85 @@
"PATCH",
"DELETE"
]
},
"auth": {
"type": "string",
"enum": [
"none",
"entra"
]
},
"entraAuthConfig": {
"type": "object",
"properties": {
"audience": {
"type": "string"
},
"issuer": {
"type": "string"
},
"scopes": {
"type": "array",
"items": {
"type": "string"
}
},
"roles": {
"type": "array",
"items": {
"type": "string"
}
},
"validateLifetime": {
"type": "boolean"
},
"validateSigningKey": {
"type": "boolean"
}
}
}
},
"required": [
"action"
],
"additionalProperties": false
}
},
"auth": {
"type": "string",
"enum": [
"none",
"entra"
]
},
"entraAuthConfig": {
"type": "object",
"properties": {
"audience": {
"type": "string"
},
"issuer": {
"type": "string"
},
"scopes": {
"type": "array",
"items": {
"type": "string"
}
},
"roles": {
"type": "array",
"items": {
"type": "string"
}
},
"validateLifetime": {
"type": "boolean"
},
"validateSigningKey": {
"type": "boolean"
}
}
}
},
"required": [
Expand Down

0 comments on commit 0bd4601

Please sign in to comment.