DPoP autentisering - eksempel

Dette er et .NET 8 konsollprogram som viser hvordan man autentiserer og konsumerer Meldingstjenerens REST-API med DPoP.

Nuget-pakker: IdentityModel og 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 MeldingstjenerApiClient;

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

		var response = await client.Get("https://localhost:7106/messages?ToHerIds=8140582&IncludeApprec=true");

		Console.WriteLine("Response:");
		Console.WriteLine(response);
		Console.WriteLine("Content:");
		Console.WriteLine(await response.Content.ReadAsStringAsync());
	}
}

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

	internal readonly string HelseIdScopes = "nhn:msh/api";
	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);

		Console.WriteLine("DPoP:");
		Console.WriteLine(dPoPProof);

		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.WriteLine("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!;
		Console.WriteLine(_cachedAccessToken);

		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)
	{
		var count = dPoPNonce == null ? "1st" : "2nd";
		Console.WriteLine($"Creating DPoP proof ({count} round) 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);
	}
}