It turns out that IdentityServer4 does not have built in support for ApiKeys... but .Net Core 3.1 has IAuthorizationHandler which allows you to roll your own authorization for ApiKeys and insert it into the flow with dependancy injection.
The way I did it was... to have an ApiKey and an ApiKeySecret. This way the UserId is not being exposed at all... I have a database table on my IdentityServer4 (Server C) called ApiKey that contains the fields (ApiKeyId, UserId, ApiKey, and ApiKeySecret)... ApiKeySecret is a one way hash like a password.
I added an ApiKeyController to my IdentityServer4 project (Server C)... this will allow an ApiRequest to Validate the ApiKeys.
So... to follow the flow:
Server A: ThirdParty .Net Core 3.1 Web Server
Server B: MyApiServer .Net Core 3.1 Web Server
Server C: MyIdentityerServer4 .Net Core 3.1 IndentityServer4
Based on a request (likely from a browser) to Server A.
Server A then calls my API (Server B) with an ApiKey and an ApiKeySecret in the headers:
using (var client = new HttpClient())
{
var url = _configuration["MyApiUrl"] + "/WeatherForecast";
var apiKey = _configuration["MyApiKey"];
var apiKeySecret = _configuration["MyApiKeySecret"];
client.DefaultRequestHeaders.Add("x-api-key", apiKey);
client.DefaultRequestHeaders.Add("secret-api-key", apiKeySecret);
var response = client.GetAsync(url).Result;
if (response.IsSuccessStatusCode)
{
var contents = response.Content.ReadAsStringAsync().Result;
return contents;
}
return "StatusCode = " + response.StatusCode;
}
On my API Server (Server B) I have added the following class which, if the [Authorize] category is set for a url, will validate the ApiKeys in the Header by calling the ApiKeyController on the IdentityServer4 (Server C) and putting the return value (UserId) on the HttpContext.Items collection.
Basically the system already defines an IAuthorizationHandler for (I believe) services.AddAuthentication("Bearer")... so when adding a second one (or more)... they will each be called, if one returns Succeeded no more will be call... if they all fail, then the [Authorized] will fail.
public class ApiKeyAuthorizationHandler : IAuthorizationHandler
{
private readonly ILogger<ApiKeyAuthorizationHandler> _logger;
private readonly IConfiguration _configuration;
private readonly IHttpContextAccessor _httpContextAccessor;
public ApiKeyAuthorizationHandler(
ILogger<ApiKeyAuthorizationHandler> logger,
IConfiguration configuration,
IHttpContextAccessor httpContextAccessor
)
{
_logger = logger;
_configuration = configuration;
_httpContextAccessor = httpContextAccessor;
}
public Task HandleAsync(AuthorizationHandlerContext context)
{
try
{
string apiKey = _httpContextAccessor.HttpContext.Request.Headers["x-api-key"].FirstOrDefault();
string apiKeySecret = _httpContextAccessor.HttpContext.Request.Headers["secret-api-key"].FirstOrDefault();
if (apiKey != null && apiKeySecret != null)
{
if (Authorize(apiKey, apiKeySecret))
SetSucceeded(context);
}
return Task.CompletedTask;
}
catch (Exception ex)
{
_logger.LogError(ex, "HandleAsync");
return Task.CompletedTask;
}
}
public class ValidateResponse
{
public string UserId { get; set; }
}
private bool Authorize(string apiKey, string apiKeySecret)
{
try
{
using (var client = new HttpClient())
{
var url = _configuration["AuthorizationServerUrl"] + "/api/ApiKey/Validate";
var json = JsonConvert.SerializeObject(new
{
clientId = "serverb-api", // different ApiKeys for different clients
apiKey = apiKey,
apiKeySecret = apiKeySecret
});
var response = client.PostAsync(url, new StringContent(json, Encoding.UTF8, "application/json")).Result;
if (response.IsSuccessStatusCode)
{
var contents = response.Content.ReadAsStringAsync().Result;
var result = JsonConvert.DeserializeObject<ValidateResponse>(contents);
_httpContextAccessor.HttpContext.Items.Add("UserId", result.UserId);
}
return response.IsSuccessStatusCode;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Authorize");
return false;
}
}
private void SetSucceeded(AuthorizationHandlerContext context)
{
var pendingRequirements = context.PendingRequirements.ToList();
foreach (var requirement in pendingRequirements)
{
context.Succeed(requirement);
}
}
}
I also need to add the following to Startup.cs on Server B:
services.AddSingleton<IAuthorizationHandler, ApiKeyAuthorizationHandler>();
And for completeness my code on IdentityServer4 (Server C):
ApiKeyController.cs
using System;
using MyIdentityServer.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace MyIdentityServer
{
[Route("api/[controller]")]
[ApiController]
public class ApiKeyController : ControllerBase
{
private readonly ILogger<ApiKeyController> _logger;
private readonly IApiKeyService _apiKeyService;
public ApiKeyController(
IApiKeyService apiKeyService,
ILogger<ApiKeyController> logger
)
{
_apiKeyService = apiKeyService;
_logger = logger;
}
public class ValidateApiKeyRequest
{
public string ClientId { get; set; }
public string ApiKey { get; set; }
public string ApiKeySecret { get; set; }
}
[HttpPost("Validate")]
[AllowAnonymous]
[Consumes("application/json")]
public IActionResult PostBody([FromBody] ValidateApiKeyRequest request)
{
try
{
(var clientId, var userId) = _apiKeyService.Verify(request.ApiKey, request.ApiKeySecret);
if (request.ClientId == clientId && userId != null)
return Ok(new { UserId = userId });
// return new JsonResult(new { UserId = userId }); // maybe also return claims for client / user
return Unauthorized();
}
catch (Exception ex)
{
_logger.LogError(ex, "HandleValidateApiKey apiKey={request.ApiKey} apiKeySecret={request.ApiKeySecret}");
return Unauthorized();
}
}
public class GenerateApiKeyRequest
{
public string ClientId { get; set; }
public string UserId { get; set; }
}
[HttpPost("Generate")]
[AllowAnonymous]
public IActionResult Generate(GenerateApiKeyRequest request)
{
// generate and store in database
(var apiKey, var apiKeySecret) = _apiKeyService.Generate(request.ClientId, request.UserId);
return new JsonResult(new { ApiKey = apiKey, ApiKeySecret = apiKeySecret });
}
}
}
ApiKeyService.cs
using Arch.EntityFrameworkCore.UnitOfWork;
using EQIdentityServer.Data.Models;
using System;
using System.Security.Cryptography;
public namespace MyIndentityServer4.Services
public interface IApiKeyService
{
(string, string) Verify(string apiKey, string apiKeySecret);
(string, string) Generate(string clientId, string userId);
}
public class ApiKeyService : IApiKeyService
{
IUnitOfWork _unitOfWork;
public ApiKeyService(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
public (string, string) Verify(string apiKey, string apiKeySecret)
{
var repoApiKey = _unitOfWork.GetRepository<ClientUserApiKey>();
var item = repoApiKey.GetFirstOrDefault(predicate: p => p.ApiKey == apiKey);
if (item == null)
return (null, null);
if (!OneWayHash.Verify(item.ApiKeySecretHash, apiKeySecret))
return (null, null);
return (item?.ClientId, item?.UserId);
}
public (string, string) Generate(string clientId, string userId)
{
var repoApiKey = _unitOfWork.GetRepository<ClientUserApiKey>();
string apiKey = null;
string apiKeySecret = null;
string apiKeySecretHash = null;
var key = new byte[30];
using (var generator = RandomNumberGenerator.Create())
generator.GetBytes(key);
apiKeySecret = Convert.ToBase64String(key);
apiKeySecretHash = OneWayHash.Hash(apiKeySecret);
var item = repoApiKey.GetFirstOrDefault(
predicate: p => p.ClientId == clientId && p.UserId == userId
);
if (item != null)
{
// regenerate only secret for existing clientId/userId
apiKey = item.ApiKey; // item.ApiKey = apiKey; // keep this the same, or you could have multiple for a clientId if you want
item.ApiKeySecretHash = apiKeySecretHash;
repoApiKey.Update(item);
}
else
{
// new for user
key = new byte[30];
while (true)
{
using (var generator = RandomNumberGenerator.Create())
generator.GetBytes(key);
apiKey = Convert.ToBase64String(key);
var existing = repoApiKey.GetFirstOrDefault(
predicate: p => p.ApiKey == apiKey
);
if (existing == null)
break;
}
item = new ClientUserApiKey() { ClientId = clientId, UserId = userId, ApiKey = apiKey, ApiKeySecretHash = apiKeySecretHash };
repoApiKey.Insert(item);
}
_unitOfWork.SaveChanges();
return (apiKey, apiKeySecret);
}
}
My Model:
public class ClientUserApiKey
{
public long ClientUserApiKeyId { get; set; }
[IndexColumn("IX_ApiKey_ClientIdUserId", 0)]
public string ClientId { get; set; }
[IndexColumn("IX_ApiKey_ClientIdUserId", 1)]
public string UserId { get; set; }
[IndexColumn]
public string ApiKey { get; set; }
[StringLength(128)]
public string ApiKeySecretHash { get; set; }
}
And, then my WeatherForecastController can get the logged in user one of two ways... via a Bearer access_token or my ApiKeys:
string userId = null;
if (User?.Identity.IsAuthenticated == true)
userId = User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier).Value;
else
userId = this.HttpContext.Items["UserId"]?.ToString(); // this comes from ApiKey validation