From cb9291cfbbbb5f7c2c7c0b98735e9d8823027457 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Thu, 22 Feb 2024 16:34:02 +0100 Subject: [PATCH] Extends CrudApiPlugin to support Entra auth. Closes #556 (#574) * Extends CrudApiPlugin to support Entra auth. Closes #556 * Renames property * Updates validating roles and scopes * Updates schema and loading keys * Updates logging --- .../MockResponses/CrudApiDefinitionLoader.cs | 4 +- .../MockResponses/CrudApiPlugin.cs | 195 +++++++++++++++++- dev-proxy-plugins/dev-proxy-plugins.csproj | 4 + dev-proxy/dev-proxy.csproj | 1 + schemas/v0.15.0/crudapiplugin.schema.json | 74 ++++++- 5 files changed, 274 insertions(+), 4 deletions(-) diff --git a/dev-proxy-plugins/MockResponses/CrudApiDefinitionLoader.cs b/dev-proxy-plugins/MockResponses/CrudApiDefinitionLoader.cs index a3d74545..fe589035 100644 --- a/dev-proxy-plugins/MockResponses/CrudApiDefinitionLoader.cs +++ b/dev-proxy-plugins/MockResponses/CrudApiDefinitionLoader.cs @@ -37,6 +37,8 @@ public void LoadApiDefinition() var apiDefinitionConfig = JsonSerializer.Deserialize(apiDefinitionString); _configuration.BaseUrl = apiDefinitionConfig?.BaseUrl ?? string.Empty; _configuration.DataFile = apiDefinitionConfig?.DataFile ?? string.Empty; + _configuration.Auth = apiDefinitionConfig?.Auth ?? CrudApiAuthType.None; + _configuration.EntraAuthConfig = apiDefinitionConfig?.EntraAuthConfig; IEnumerable? configResponses = apiDefinitionConfig?.Actions; if (configResponses is not null) @@ -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); } } diff --git a/dev-proxy-plugins/MockResponses/CrudApiPlugin.cs b/dev-proxy-plugins/MockResponses/CrudApiPlugin.cs index a15a2459..ab32f551 100644 --- a/dev-proxy-plugins/MockResponses/CrudApiPlugin.cs +++ b/dev-proxy-plugins/MockResponses/CrudApiPlugin.cs @@ -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; @@ -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(); + [JsonPropertyName("roles")] + public string[] Roles { get; set; } = Array.Empty(); + [JsonPropertyName("validateLifetime")] + public bool ValidateLifetime { get; set; } = false; + [JsonPropertyName("validateSigningKey")] + public bool ValidateSigningKey { get; set; } = false; +} + public class CrudApiAction { [JsonPropertyName("action")] @@ -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 @@ -49,6 +82,11 @@ public class CrudApiConfiguration public string DataFile { get; set; } = string.Empty; [JsonPropertyName("actions")] public IEnumerable Actions { get; set; } = Array.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 CrudApiPlugin : BaseProxyPlugin @@ -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 urlsToWatch, IConfigurationSection? configSection = null) @@ -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("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() @@ -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); } } @@ -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; } @@ -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); diff --git a/dev-proxy-plugins/dev-proxy-plugins.csproj b/dev-proxy-plugins/dev-proxy-plugins.csproj index 48b00d8f..20cb6825 100644 --- a/dev-proxy-plugins/dev-proxy-plugins.csproj +++ b/dev-proxy-plugins/dev-proxy-plugins.csproj @@ -21,6 +21,10 @@ false runtime + + false + runtime + false runtime diff --git a/dev-proxy/dev-proxy.csproj b/dev-proxy/dev-proxy.csproj index a1a88395..58125a84 100644 --- a/dev-proxy/dev-proxy.csproj +++ b/dev-proxy/dev-proxy.csproj @@ -33,6 +33,7 @@ + diff --git a/schemas/v0.15.0/crudapiplugin.schema.json b/schemas/v0.15.0/crudapiplugin.schema.json index 81490a3a..286a3c2c 100644 --- a/schemas/v0.15.0/crudapiplugin.schema.json +++ b/schemas/v0.15.0/crudapiplugin.schema.json @@ -5,7 +5,7 @@ "type": "object", "properties": { "$schema": { - "type":"string" + "type": "string" }, "baseUrl": { "type": "string" @@ -45,6 +45,42 @@ "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": [ @@ -52,6 +88,42 @@ ], "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": [