IdentityServer4 using ApiKey or Basic authentication directly to API

Viewed 3248

I am using IdentityServer4 to have my customers login and access web pages and api's from JavaScript and it is working well. However, there is a new requirement that rather than using username and password to get an access token from the identity server and then using that to access the api with Bearer authentication... I would need to call the api directly with a "Basic" authentication header and the api would confirm the identity with the identity server. Similar to the code below that is used to access the ZenDesk api...

        using (var client = new HttpClient())
        {
            var username = _configuration["ZenDesk:username"];
            var password = _configuration["ZenDesk:password"];
            var token = Convert.ToBase64String(Encoding.ASCII.GetBytes(username + ":" + password));
            client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", token);

            var response = client.PostAsync("https://...

Any help on how I would implement this? Is there anything built into IdentityServer4 that would accommodate this approach? I am using .Net Core 3.1 for both the api server and for the identity server.

Another (seemingly common) approach would be to generate an api key for each user and then allow the user to call the api like this...

using (var client = new HttpClient())
{
    client.BaseAddress = new Uri(URL_HOST_API);
    client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("ApiKey", "123456456123456789");
…
}

Thoughts?

3 Answers

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

You can implement OAuth password grant, as this is a server to server authentication. To implement follow these steps:

  1. Register a client on IdentityServer for your API project, here is an example
public static IEnumerable<Client> GetClients()
{
    return new List<Client>
    {
        new Client
        {
            ClientId = "resourceownerclient",
            ClientSecrets=  new List<Secret> { new Secret("secret".Sha256()) },

            AllowedGrantTypes = GrantTypes.ResourceOwnerPasswordAndClientCredentials,
            AccessTokenType = AccessTokenType.Jwt,
            AccessTokenLifetime = 120, //86400,
            IdentityTokenLifetime = 120, //86400,
            UpdateAccessTokenClaimsOnRefresh = true,
            SlidingRefreshTokenLifetime = 30,
            AllowOfflineAccess = true,
            RefreshTokenExpiration = TokenExpiration.Absolute,
            RefreshTokenUsage = TokenUsage.OneTimeOnly,
            AlwaysSendClientClaims = true,
            Enabled = true,
      
            AllowedScopes = {
                IdentityServerConstants.StandardScopes.OpenId, 
                IdentityServerConstants.StandardScopes.Profile,
                IdentityServerConstants.StandardScopes.Email,
                IdentityServerConstants.StandardScopes.OfflineAccess
            }
        }
  
  1. Install IdentityModel package on API project https://www.nuget.org/packages/IdentityModel/

  2. Request a token on API using username/password received on header like this:

private static async Task<TokenResponse> RequestTokenAsync(string user, string password)
{
    var _httpClient = new HttpClient();
    var _disco = await HttpClientDiscoveryExtensions.GetDiscoveryDocumentAsync(
                    _httpClient,
                    _stsUrl);
    var response = await _httpClient.RequestPasswordTokenAsync(new PasswordTokenRequest
    {
        Address = _disco.TokenEndpoint,
 
        ClientId = "resourceownerclient",
        ClientSecret = "secret",
        Scope = "email openid offline_access",
 
        UserName = user,
        Password = password
    });
 
    return response;
}
  1. Check response - IsError property to make sure the request was successful

  2. If you need to customize any thing on IdentityServer for password grant, you need to implement IResourceOwnerPasswordValidator,read more here

@nahidf has a good approach, i will just add that you can also use a custom Resource Owner Password validator. It grants you the possibility of implementing your own password validator like so

[edit] : This is for you to don't have to use identity model


public class ApiResourceOwnerPasswordValidation : IResourceOwnerPasswordValidator
{
        public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
        {
            var user = await _dbContext.Users.FirstOrDefaultAsync(a =>
            (a.UserName == context.UserName || a.Email.ToLower().Trim() == context.UserName.ToLower().Trim())
            && context.Password.VerifyHashedPassword(a.Password));

            if (user != null)
            {
                context.Result = new GrantValidationResult(
                    subject: user.Id.ToString(),
                    authenticationMethod: "custom",
                    claims: AssambleClaims(user)
                );

                user.LoggedIn = DateTime.UtcNow;
                await _dbContext.CommitAsync();
            }
            else
            {
                context.Result = new GrantValidationResult(
                    TokenRequestErrors.InvalidGrant,
                    "Invalid credentials");
            }

        }
}

Related