Skip to content

Commit d0da2d7

Browse files
authored
Add API Key authentication support to CRUD API plugin (#1655)
* Add API Key authentication support to CRUD API plugin * Refactor API Key authentication configuration to allow null values for header and query parameter names * Fix URL matching in CrudApiPlugin to exclude query parameters * Remove API Key authentication support from CrudApiPlugin and update schema accordingly
1 parent 031af7e commit d0da2d7

3 files changed

Lines changed: 117 additions & 7 deletions

File tree

DevProxy.Plugins/Mocking/CrudApiDefinitionLoader.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ protected override void LoadData(string fileContents)
3030
_configuration.DataFile = apiDefinitionConfig?.DataFile ?? string.Empty;
3131
_configuration.Auth = apiDefinitionConfig?.Auth ?? CrudApiAuthType.None;
3232
_configuration.EntraAuthConfig = apiDefinitionConfig?.EntraAuthConfig;
33+
_configuration.ApiKeyAuthConfig = apiDefinitionConfig?.ApiKeyAuthConfig;
3334

3435
var configResponses = apiDefinitionConfig?.Actions;
3536
if (configResponses is not null)

DevProxy.Plugins/Mocking/CrudApiPlugin.cs

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ public enum CrudApiActionType
3939
public enum CrudApiAuthType
4040
{
4141
None,
42-
Entra
42+
Entra,
43+
ApiKey
4344
}
4445

4546
public sealed class CrudApiEntraAuth
@@ -52,6 +53,13 @@ public sealed class CrudApiEntraAuth
5253
public bool ValidateSigningKey { get; set; }
5354
}
5455

56+
public sealed class CrudApiApiKeyAuth
57+
{
58+
public string ApiKey { get; set; } = string.Empty;
59+
public string? HeaderName { get; set; }
60+
public string? QueryParameterName { get; set; }
61+
}
62+
5563
public sealed class CrudApiAction
5664
{
5765
[System.Text.Json.Serialization.JsonConverter(typeof(JsonStringEnumConverter))]
@@ -67,6 +75,7 @@ public sealed class CrudApiAction
6775
public sealed class CrudApiConfiguration
6876
{
6977
public IEnumerable<CrudApiAction> Actions { get; set; } = [];
78+
public CrudApiApiKeyAuth? ApiKeyAuthConfig { get; set; }
7079
public string ApiFile { get; set; } = "api.json";
7180
[System.Text.Json.Serialization.JsonConverter(typeof(JsonStringEnumConverter))]
7281
public CrudApiAuthType Auth { get; set; } = CrudApiAuthType.None;
@@ -115,6 +124,21 @@ public override async Task InitializeAsync(InitArgs e, CancellationToken cancell
115124
Configuration.Auth = CrudApiAuthType.None;
116125
}
117126

127+
if (Configuration.Auth == CrudApiAuthType.ApiKey &&
128+
Configuration.ApiKeyAuthConfig is null)
129+
{
130+
Logger.LogError("API Key auth is enabled but no configuration is provided. API will work anonymously.");
131+
Configuration.Auth = CrudApiAuthType.None;
132+
}
133+
134+
if (Configuration.Auth == CrudApiAuthType.ApiKey &&
135+
Configuration.ApiKeyAuthConfig is not null &&
136+
string.IsNullOrEmpty(Configuration.ApiKeyAuthConfig.ApiKey))
137+
{
138+
Logger.LogError("API Key auth is enabled but no API key is configured. API will work anonymously.");
139+
Configuration.Auth = CrudApiAuthType.None;
140+
}
141+
118142
if (!ProxyUtils.MatchesUrlToWatch(UrlsToWatch, Configuration.BaseUrl, true))
119143
{
120144
Logger.LogWarning(
@@ -221,6 +245,7 @@ private async Task SetupOpenIdConnectConfigurationAsync()
221245
return $"(?<{paramName}>[^/&]+)";
222246
});
223247

248+
var requestUrlWithoutQuery = request.RequestUri.GetLeftPart(UriPartial.Path);
224249
var parameters = new Dictionary<string, string>();
225250
var action = Configuration.Actions.FirstOrDefault(action =>
226251
{
@@ -231,7 +256,7 @@ private async Task SetupOpenIdConnectConfigurationAsync()
231256

232257
var absoluteActionUrl = (Configuration.BaseUrl + action.Url).Replace("//", "/", 8);
233258

234-
if (absoluteActionUrl == request.Url)
259+
if (absoluteActionUrl == requestUrlWithoutQuery)
235260
{
236261
return true;
237262
}
@@ -245,7 +270,7 @@ private async Task SetupOpenIdConnectConfigurationAsync()
245270

246271
// convert parameters into named regex groups
247272
var urlRegex = Regex.Replace(Regex.Escape(absoluteActionUrl).Replace("\\{", "{", StringComparison.OrdinalIgnoreCase), "({[^}]+})", parameterMatchEvaluator);
248-
var match = Regex.Match(request.Url, urlRegex);
273+
var match = Regex.Match(requestUrlWithoutQuery, urlRegex);
249274
if (!match.Success)
250275
{
251276
return false;
@@ -290,12 +315,25 @@ private void AddCORSHeaders(Request request, List<HttpHeader> headers)
290315

291316
headers.Add(new HttpHeader("access-control-allow-origin", origin));
292317

318+
var allowHeaders = new List<string> { "content-type" };
319+
293320
if (Configuration.EntraAuthConfig is not null ||
294321
Configuration.Actions.Any(a => a.Auth == CrudApiAuthType.Entra))
295322
{
296-
headers.Add(new HttpHeader("access-control-allow-headers", "authorization, content-type"));
323+
allowHeaders.Add("authorization");
297324
}
298325

326+
if (Configuration.ApiKeyAuthConfig is not null &&
327+
!string.IsNullOrEmpty(Configuration.ApiKeyAuthConfig.HeaderName))
328+
{
329+
if (!allowHeaders.Contains(Configuration.ApiKeyAuthConfig.HeaderName, StringComparer.OrdinalIgnoreCase))
330+
{
331+
allowHeaders.Add(Configuration.ApiKeyAuthConfig.HeaderName);
332+
}
333+
}
334+
335+
headers.Add(new HttpHeader("access-control-allow-headers", string.Join(", ", allowHeaders)));
336+
299337
var methods = string.Join(", ", Configuration.Actions
300338
.Where(a => a.Method is not null)
301339
.Select(a => a.Method)
@@ -307,7 +345,6 @@ private void AddCORSHeaders(Request request, List<HttpHeader> headers)
307345
private bool AuthorizeRequest(ProxyRequestArgs e, CrudApiAction? action = null)
308346
{
309347
var authType = action is null ? Configuration.Auth : action.Auth;
310-
var authConfig = action is null ? Configuration.EntraAuthConfig : action.EntraAuthConfig;
311348

312349
if (authType == CrudApiAuthType.None)
313350
{
@@ -318,6 +355,56 @@ private bool AuthorizeRequest(ProxyRequestArgs e, CrudApiAction? action = null)
318355
return true;
319356
}
320357

358+
if (authType == CrudApiAuthType.ApiKey)
359+
{
360+
return AuthorizeApiKeyRequest(e);
361+
}
362+
363+
return AuthorizeEntraRequest(e, action);
364+
}
365+
366+
private bool AuthorizeApiKeyRequest(ProxyRequestArgs e)
367+
{
368+
var apiKeyAuthConfig = Configuration.ApiKeyAuthConfig;
369+
370+
Debug.Assert(apiKeyAuthConfig is not null, "ApiKeyAuthConfig is null when API key auth is required.");
371+
372+
// Check header
373+
if (!string.IsNullOrEmpty(apiKeyAuthConfig.HeaderName))
374+
{
375+
var headerValue = e.Session.HttpClient.Request.Headers
376+
.FirstOrDefault(h => h.Name.Equals(apiKeyAuthConfig.HeaderName, StringComparison.OrdinalIgnoreCase))?.Value;
377+
378+
if (!string.IsNullOrEmpty(headerValue) && headerValue == apiKeyAuthConfig.ApiKey)
379+
{
380+
return true;
381+
}
382+
}
383+
384+
// Check query parameter
385+
if (!string.IsNullOrEmpty(apiKeyAuthConfig.QueryParameterName))
386+
{
387+
var requestUrl = e.Session.HttpClient.Request.RequestUri;
388+
var queryString = requestUrl.Query;
389+
if (!string.IsNullOrEmpty(queryString))
390+
{
391+
var queryParams = System.Web.HttpUtility.ParseQueryString(queryString);
392+
var queryValue = queryParams[apiKeyAuthConfig.QueryParameterName];
393+
if (!string.IsNullOrEmpty(queryValue) && queryValue == apiKeyAuthConfig.ApiKey)
394+
{
395+
return true;
396+
}
397+
}
398+
}
399+
400+
Logger.LogRequest("401 Unauthorized. The specified API key is not valid.", MessageType.Failed, new LoggingContext(e.Session));
401+
return false;
402+
}
403+
404+
private bool AuthorizeEntraRequest(ProxyRequestArgs e, CrudApiAction? action = null)
405+
{
406+
var authConfig = action is null ? Configuration.EntraAuthConfig : action.EntraAuthConfig;
407+
321408
Debug.Assert(authConfig is not null, "EntraAuthConfig is null when auth is required.");
322409

323410
var token = e.Session.HttpClient.Request.Headers.FirstOrDefault(h => h.Name.Equals("Authorization", StringComparison.OrdinalIgnoreCase))?.Value;

schemas/v3.0.0/crudapiplugin.apifile.schema.json

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,31 @@
109109
"type": "string",
110110
"enum": [
111111
"none",
112-
"entra"
112+
"entra",
113+
"apiKey"
113114
],
114-
"description": "Determines if the API is secured. Allowed values: none, entra. Default is none."
115+
"description": "Determines if the API is secured. Allowed values: none, entra, apiKey. Default is none."
116+
},
117+
"apiKeyAuthConfig": {
118+
"type": "object",
119+
"description": "Configuration for API Key authentication. Applies to all actions unless overridden at the action level.",
120+
"properties": {
121+
"apiKey": {
122+
"type": "string",
123+
"description": "The valid API key that must be present in the request."
124+
},
125+
"headerName": {
126+
"type": "string",
127+
"description": "The HTTP header name to read the API key from."
128+
},
129+
"queryParameterName": {
130+
"type": "string",
131+
"description": "The name of the query-string parameter to read the API key from."
132+
}
133+
},
134+
"required": [
135+
"apiKey"
136+
]
115137
},
116138
"entraAuthConfig": {
117139
"type": "object",

0 commit comments

Comments
 (0)