DPoP authentication example

This is a .NET 8 console program showing how to authenticate and consume FLR Public API with DPoP.

Package dependencies are IdentityModel and System.IdentityModel.Tokens.Jwt.

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using IdentityModel;
using IdentityModel.Client;
using Microsoft.IdentityModel.Tokens;
using static IdentityModel.OidcConstants;
using JsonWebKey = Microsoft.IdentityModel.Tokens.JsonWebKey;
using TokenResponse = IdentityModel.Client.TokenResponse;

namespace FlrPublicClient;

internal static class Program
{
    public static async Task Main()
    {
        var client = new RestWithDPoPClient(new HelseIdConfig());

        var res = await client.Get("https://api.offentlig.test.flr.nhn.no/contracts");

        Console.WriteLine("Response:");
        Console.WriteLine(res);
    }
}

internal record HelseIdConfig
{
    // MUST BE CONFIGURED!
    internal readonly string HelseIdClientId = "";
    internal readonly string HelseIdPrivateJwk = "";

    internal readonly string HelseIdScopes = "nhn:flr-public/read";
    internal readonly string TokenEndpoint = "https://helseid-sts.test.nhn.no/connect/token";

    internal HelseIdConfig()
    {
        if (string.IsNullOrEmpty(HelseIdClientId) || string.IsNullOrEmpty(HelseIdPrivateJwk) || string.IsNullOrEmpty(HelseIdScopes))
        {
            throw new Exception($"{nameof(HelseIdClientId)} and {nameof(HelseIdPrivateJwk)} must be configured.");
        }
    }
}

internal class RestWithDPoPClient
{
    private readonly HttpClient _httpClient;
    private readonly DPoPProofCreator _dPoPProofCreator;
    private readonly HelseIdService _helseIdService;

    public RestWithDPoPClient(HelseIdConfig helseIdConfig)
    {
        _httpClient = new HttpClient();
        _dPoPProofCreator = new DPoPProofCreator(helseIdConfig.HelseIdPrivateJwk);
        _helseIdService = new HelseIdService(_httpClient, _dPoPProofCreator, helseIdConfig);
    }

    internal async Task<HttpResponseMessage> Get(string url)
    {
        var accessToken = await _helseIdService.GetAccessToken();

        var dPoPProof = _dPoPProofCreator.CreateDPoPProof(url, "GET", null, accessToken);

        var response = await Get(url, accessToken, dPoPProof);

        return response;
    }

    private async Task<HttpResponseMessage> Get(string url, string accessToken, string dPoPProof)
    {
        var requestMessage = new HttpRequestMessage(HttpMethod.Get, url);
        requestMessage.SetDPoPToken(accessToken, dPoPProof);

        Console.Write("Sending request...");
        var response = await _httpClient.SendAsync(requestMessage);

        return response;
    }
}

internal class HelseIdService(HttpClient httpClient, DPoPProofCreator dPoPProofCreator, HelseIdConfig helseIdConfig)
{
    private DateTime _cachedAccessTokenExpiresAt = DateTime.MinValue;
    private string _cachedAccessToken = string.Empty;

    internal async Task<string> GetAccessToken()
    {
        if (DateTime.UtcNow <= _cachedAccessTokenExpiresAt)
        {
            Console.WriteLine("Using cached DPoP access token");
            return _cachedAccessToken;
        }

        Console.WriteLine("Getting DPoP access token from HelseId");
        var tokenResponse = await GetAccessTokenFromHelseId();

        _cachedAccessTokenExpiresAt = DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn - 30); // Refresh token 30 seconds ahead of expiry
        _cachedAccessToken = tokenResponse.AccessToken!;

        return _cachedAccessToken;
    }

    private async Task<TokenResponse> GetAccessTokenFromHelseId()
    {
        // 1. Send a token request without a DPoP nonce
        var firstRequest = CreateTokenRequest();
        var firstTokenResponse = await httpClient.RequestClientCredentialsTokenAsync(firstRequest);
        if (!firstTokenResponse.IsError || string.IsNullOrEmpty(firstTokenResponse.DPoPNonce))
        {
            throw new Exception("Expected a DPoP nonce to be returned from the authorization server.");
        }

        // 2. Send a second token request with the DPoP nonce from the first response
        var secondRequest = CreateTokenRequest(firstTokenResponse.DPoPNonce);
        var secondTokenResponse = await httpClient.RequestClientCredentialsTokenAsync(secondRequest);
        if (secondTokenResponse.IsError || secondTokenResponse.AccessToken == null)
        {
            throw new Exception($"Error retrieving access token: {secondTokenResponse.Error}");
        }

        return secondTokenResponse;
    }

    private ClientCredentialsTokenRequest CreateTokenRequest(string? dPoPNonce = null)
    {
        var securityKey = new JsonWebKey(helseIdConfig.HelseIdPrivateJwk);
        var signingCredentials = new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha512);

        var claims = new List<Claim>
        {
            new(JwtClaimTypes.Subject, helseIdConfig.HelseIdClientId),
            new(JwtClaimTypes.IssuedAt, new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
            new(JwtClaimTypes.JwtId, Guid.NewGuid().ToString("N"))
        };

        var token = new JwtSecurityToken(helseIdConfig.HelseIdClientId, helseIdConfig.TokenEndpoint, claims, DateTime.Now, DateTime.Now.AddMinutes(1), signingCredentials);

        var tokenHandler = new JwtSecurityTokenHandler();
        var clientAssertion = tokenHandler.WriteToken(token);

        var request = new ClientCredentialsTokenRequest
        {
            Address = helseIdConfig.TokenEndpoint,
            ClientAssertion = new ClientAssertion { Value = clientAssertion, Type = ClientAssertionTypes.JwtBearer },
            ClientId = helseIdConfig.HelseIdClientId,
            Scope = helseIdConfig.HelseIdScopes,
            GrantType = GrantTypes.ClientCredentials,
            ClientCredentialStyle = ClientCredentialStyle.PostBody,
            DPoPProofToken = dPoPProofCreator.CreateDPoPProof(helseIdConfig.TokenEndpoint, "POST", dPoPNonce: dPoPNonce)
        };

        return request;
    }
}

internal class DPoPProofCreator(string privateJwk)
{
    public string CreateDPoPProof(string url, string httpMethod, string? dPoPNonce = null, string? accessToken = null)
    {
        Console.WriteLine($"Creating DPoP proof for url {url}");

        var securityKey = new JsonWebKey(privateJwk);
        var signingCredentials = new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha512);

        var jwk = securityKey.Kty switch
        {
            JsonWebAlgorithmsKeyTypes.EllipticCurve => new Dictionary<string, string>
            {
                [JsonWebKeyParameterNames.Kty] = securityKey.Kty,
                [JsonWebKeyParameterNames.X] = securityKey.X,
                [JsonWebKeyParameterNames.Y] = securityKey.Y,
                [JsonWebKeyParameterNames.Crv] = securityKey.Crv,
            },
            JsonWebAlgorithmsKeyTypes.RSA => new Dictionary<string, string>
            {
                [JsonWebKeyParameterNames.Kty] = securityKey.Kty,
                [JsonWebKeyParameterNames.N] = securityKey.N,
                [JsonWebKeyParameterNames.E] = securityKey.E,
                [JsonWebKeyParameterNames.Alg] = signingCredentials.Algorithm,
            },
            _ => throw new InvalidOperationException("Invalid key type for DPoP proof.")
        };

        var jwtHeader = new JwtHeader(signingCredentials)
        {
            [JwtClaimTypes.TokenType] = "dpop+jwt",
            [JwtClaimTypes.JsonWebKey] = jwk,
        };

        var urlWithoutQuery = url.Split('?')[0];
        var payload = new JwtPayload
        {
            [JwtClaimTypes.JwtId] = Guid.NewGuid().ToString(),
            [JwtClaimTypes.DPoPHttpMethod] = httpMethod,
            [JwtClaimTypes.DPoPHttpUrl] = urlWithoutQuery,
            [JwtClaimTypes.IssuedAt] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
        };

        // Used when accessing the authentication server (HelseID):
        if (!string.IsNullOrEmpty(dPoPNonce))
        {
            // nonce: A recent nonce provided via the DPoP-Nonce HTTP header.
            payload[JwtClaimTypes.Nonce] = dPoPNonce;
        }

        // Used when accessing an API that requires a DPoP token:
        if (!string.IsNullOrEmpty(accessToken))
        {
            // ath: hash of the access token. The value MUST be the result of a base64url encoding
            // the SHA-256 [SHS] hash of the ASCII encoding of the associated access token's value. 
            var hash = SHA256.HashData(Encoding.ASCII.GetBytes(accessToken));
            var ath = Base64Url.Encode(hash);

            payload[JwtClaimTypes.DPoPAccessTokenHash] = ath;
        }

        var jwtSecurityToken = new JwtSecurityToken(jwtHeader, payload);
        return new JwtSecurityTokenHandler().WriteToken(jwtSecurityToken);
    }
}