├── renovate.json ├── src └── Altinn.ApiClients.Maskinporten │ ├── Properties │ └── launchSettings.json │ ├── Models │ ├── ClientSecrets.cs │ ├── TokenRequestException.cs │ ├── ErrorResponse.cs │ └── TokenResponse.cs │ ├── Interfaces │ ├── IClientDefinition.cs │ ├── ITokenCacheProvider.cs │ ├── IMaskinportenService.cs │ └── IMaskinportenSettings.cs │ ├── Services │ ├── SettingsJwkClientDefinition.cs │ ├── Pkcs12ClientDefinition.cs │ ├── MemoryTokenCacheProvider.cs │ ├── SettingsX509ClientDefinition.cs │ ├── CertificateStoreClientDefinition.cs │ ├── FileTokenCacheProvider.cs │ └── MaskinportenService.cs │ ├── Helpers │ └── MaskinportenClientDefinitionHelper.cs │ ├── Altinn.ApiClients.Maskinporten.csproj │ ├── Altinn.ApiClients.Maskinporten.sln │ ├── Extensions │ ├── HttpClientBuilderExtensions.cs │ └── ServiceCollectionExtensions.cs │ ├── Handlers │ └── MaskinportenTokenHandler.cs │ ├── Factories │ └── MaskinportenHttpMessageHandlerFactory.cs │ └── Config │ └── MaskinportenSettings.cs ├── samples └── SampleWebApp │ ├── appsettings.Development.json │ ├── Config │ └── MyCustomClientDefinitionSettings.cs │ ├── Program.cs │ ├── SampleWebApp.csproj │ ├── Properties │ └── launchSettings.json │ ├── MyMaskinportenHttpClient.cs │ ├── appsettings.json │ ├── MyCustomClientDefinition.cs │ ├── Controllers │ └── MaskinportenTestController.cs │ └── Startup.cs ├── LICENSE ├── .github └── workflows │ ├── ci-nuget.yml │ └── release-nuget.yml ├── .gitignore └── README.md /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>Altinn/renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/Altinn.ApiClients.Maskinporten/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "Altinn.ApiClients.Maskinporten": { 4 | "commandName": "Project" 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /samples/SampleWebApp/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /samples/SampleWebApp/Config/MyCustomClientDefinitionSettings.cs: -------------------------------------------------------------------------------- 1 | using Altinn.ApiClients.Maskinporten.Config; 2 | 3 | namespace SampleWebApp.Config 4 | { 5 | public class MyCustomClientDefinitionSettings : MaskinportenSettings 6 | { 7 | public string AzureKeyVaultName { get; set; } 8 | public string SecretName { get; set; } 9 | } 10 | 11 | } 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/Altinn.ApiClients.Maskinporten/Models/ClientSecrets.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.IdentityModel.Tokens; 2 | using System.Security.Cryptography.X509Certificates; 3 | 4 | namespace Altinn.ApiClients.Maskinporten.Models 5 | { 6 | public class ClientSecrets 7 | { 8 | public JsonWebKey ClientKey { get; set; } 9 | public X509Certificate2 ClientCertificate { get; set; } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Altinn.ApiClients.Maskinporten/Interfaces/IClientDefinition.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Altinn.ApiClients.Maskinporten.Config; 3 | using Altinn.ApiClients.Maskinporten.Models; 4 | 5 | namespace Altinn.ApiClients.Maskinporten.Interfaces 6 | { 7 | public interface IClientDefinition 8 | { 9 | IMaskinportenSettings ClientSettings { get; set; } 10 | Task GetClientSecrets(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Altinn.ApiClients.Maskinporten/Interfaces/ITokenCacheProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Altinn.ApiClients.Maskinporten.Models; 4 | 5 | namespace Altinn.ApiClients.Maskinporten.Interfaces 6 | { 7 | public interface ITokenCacheProvider 8 | { 9 | public Task<(bool success, TokenResponse result)> TryGetToken(string key); 10 | public Task Set(string key, TokenResponse value, TimeSpan timeToLive); 11 | } 12 | } -------------------------------------------------------------------------------- /src/Altinn.ApiClients.Maskinporten/Models/TokenRequestException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace Altinn.ApiClients.Maskinporten.Models 8 | { 9 | public class TokenRequestException : ApplicationException 10 | { 11 | public TokenRequestException(string message) 12 | : base(message) 13 | { 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /samples/SampleWebApp/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Hosting; 2 | using Microsoft.Extensions.Hosting; 3 | 4 | namespace SampleWebApp 5 | { 6 | public class Program 7 | { 8 | public static void Main(string[] args) 9 | { 10 | CreateHostBuilder(args).Build().Run(); 11 | } 12 | 13 | public static IHostBuilder CreateHostBuilder(string[] args) => 14 | Host.CreateDefaultBuilder(args) 15 | .ConfigureWebHostDefaults(webBuilder => 16 | { 17 | webBuilder.UseStartup(); 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Altinn.ApiClients.Maskinporten/Models/ErrorResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Altinn.ApiClients.Maskinporten.Models 4 | { 5 | /// 6 | /// An error from Maskinporten 7 | /// 8 | public class ErrorReponse 9 | { 10 | /// 11 | /// The type of error 12 | /// 13 | [JsonPropertyName("error")] 14 | public string ErrorType { get; set; } 15 | 16 | /// 17 | /// Description of the error 18 | /// 19 | [JsonPropertyName("error_description")] 20 | public string Description { get; set; } 21 | 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /samples/SampleWebApp/SampleWebApp.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | PreserveNewest 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /samples/SampleWebApp/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:57514", 8 | "sslPort": 44335 9 | } 10 | }, 11 | "profiles": { 12 | "IIS Express": { 13 | "commandName": "IISExpress", 14 | "launchBrowser": true, 15 | "launchUrl": "maskinportentest", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | }, 20 | "SampleWebApp": { 21 | "commandName": "Project", 22 | "launchBrowser": true, 23 | "launchUrl": "MaskinportenTest", 24 | "environmentVariables": { 25 | "ASPNETCORE_ENVIRONMENT": "Development" 26 | }, 27 | "applicationUrl": "https://localhost:5001;http://localhost:5000" 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Altinn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Altinn.ApiClients.Maskinporten/Services/SettingsJwkClientDefinition.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | using System.Threading.Tasks; 4 | using Altinn.ApiClients.Maskinporten.Config; 5 | using Altinn.ApiClients.Maskinporten.Interfaces; 6 | using Altinn.ApiClients.Maskinporten.Models; 7 | using Microsoft.IdentityModel.Tokens; 8 | 9 | namespace Altinn.ApiClients.Maskinporten.Services 10 | { 11 | public class SettingsJwkClientDefinition : IClientDefinition 12 | { 13 | public IMaskinportenSettings ClientSettings { get; set; } 14 | 15 | public async Task GetClientSecrets() 16 | { 17 | ClientSecrets clientSecrets = new ClientSecrets(); 18 | 19 | if (!string.IsNullOrEmpty(ClientSettings.EncodedJwk)) 20 | { 21 | byte[] base64EncodedBytes = Convert.FromBase64String(ClientSettings.EncodedJwk); 22 | string jwkjson = Encoding.UTF8.GetString(base64EncodedBytes); 23 | clientSecrets.ClientKey = new JsonWebKey(jwkjson); 24 | } 25 | 26 | return await Task.FromResult(clientSecrets); 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /src/Altinn.ApiClients.Maskinporten/Services/Pkcs12ClientDefinition.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Security.Cryptography.X509Certificates; 4 | using System.Threading.Tasks; 5 | using Altinn.ApiClients.Maskinporten.Config; 6 | using Altinn.ApiClients.Maskinporten.Interfaces; 7 | using Altinn.ApiClients.Maskinporten.Models; 8 | 9 | namespace Altinn.ApiClients.Maskinporten.Services 10 | { 11 | public class Pkcs12ClientDefinition : IClientDefinition 12 | { 13 | public IMaskinportenSettings ClientSettings { get; set; } 14 | 15 | public Task GetClientSecrets() 16 | { 17 | string p12KeyStoreFile = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, ClientSettings.CertificatePkcs12Path); 18 | 19 | X509Certificate2 signingCertificate = new X509Certificate2( 20 | p12KeyStoreFile, 21 | ClientSettings.CertificatePkcs12Password, 22 | X509KeyStorageFlags.EphemeralKeySet); 23 | 24 | return Task.FromResult(new ClientSecrets() 25 | { 26 | ClientCertificate = signingCertificate 27 | }); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Altinn.ApiClients.Maskinporten/Models/TokenResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Altinn.ApiClients.Maskinporten.Models 4 | { 5 | /// 6 | /// The TokenResponse from Maskinporten 7 | /// 8 | public class TokenResponse 9 | { 10 | /// 11 | /// An Oauth2 access token, either by reference or as a JWT depending on which scopes was requested and/or client registration properties. 12 | /// 13 | [JsonPropertyName("access_token")] 14 | public string AccessToken { get; set; } 15 | 16 | /// 17 | /// Number of seconds until this access_token is no longer valid 18 | /// 19 | [JsonPropertyName("expires_in")] 20 | public int ExpiresIn { get; set; } 21 | 22 | /// 23 | /// The list of scopes issued in the access token. Included for convenience only, and should not be trusted for access control decisions. 24 | /// 25 | [JsonPropertyName("scope")] 26 | public string Scope { get; set; } 27 | 28 | /// 29 | /// Type of token 30 | /// 31 | [JsonPropertyName("token_type")] 32 | public string TokenType { get; set; } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Altinn.ApiClients.Maskinporten/Services/MemoryTokenCacheProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Altinn.ApiClients.Maskinporten.Interfaces; 4 | using Altinn.ApiClients.Maskinporten.Models; 5 | using Microsoft.Extensions.Caching.Memory; 6 | 7 | namespace Altinn.ApiClients.Maskinporten.Services 8 | { 9 | public class MemoryTokenCacheProvider : ITokenCacheProvider 10 | { 11 | private readonly IMemoryCache _memoryCache; 12 | 13 | public MemoryTokenCacheProvider(IMemoryCache memoryCache) 14 | { 15 | _memoryCache = memoryCache; 16 | } 17 | public Task<(bool success, TokenResponse result)> TryGetToken(string key) 18 | { 19 | bool success = _memoryCache.TryGetValue(key, out TokenResponse result); 20 | return Task.FromResult((success, result)); 21 | } 22 | 23 | public async Task Set(string key, TokenResponse value, TimeSpan timeToLive) 24 | { 25 | MemoryCacheEntryOptions cacheEntryOptions = new MemoryCacheEntryOptions() 26 | { 27 | Priority = CacheItemPriority.High, 28 | }; 29 | 30 | cacheEntryOptions.SetAbsoluteExpiration(timeToLive); 31 | _memoryCache.Set(key, value, cacheEntryOptions); 32 | 33 | await Task.CompletedTask; 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /samples/SampleWebApp/MyMaskinportenHttpClient.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http; 2 | using System.Threading.Tasks; 3 | 4 | namespace SampleWebApp 5 | { 6 | public interface IMyMaskinportenHttpClient 7 | { 8 | Task PerformStuff(string url); 9 | } 10 | 11 | public interface IMyOtherMaskinportenHttpClient {} 12 | 13 | public class MyMaskinportenHttpClient : IMyMaskinportenHttpClient 14 | { 15 | private readonly HttpClient _httpClient; 16 | 17 | public MyMaskinportenHttpClient(HttpClient httpClient) 18 | { 19 | _httpClient = httpClient; 20 | } 21 | 22 | public async Task PerformStuff(string url) 23 | { 24 | return await _httpClient.GetAsync(url); 25 | } 26 | } 27 | 28 | 29 | public class MyOtherMaskinportenHttpClient : MyMaskinportenHttpClient, IMyOtherMaskinportenHttpClient 30 | { 31 | public MyOtherMaskinportenHttpClient(HttpClient httpClient) : base(httpClient) { } 32 | } 33 | 34 | public class MyThirdMaskinportenHttpClient : MyMaskinportenHttpClient 35 | { 36 | public MyThirdMaskinportenHttpClient(HttpClient httpClient) : base(httpClient) { } 37 | } 38 | 39 | public class MyFourthMaskinportenHttpClient : MyMaskinportenHttpClient 40 | { 41 | public MyFourthMaskinportenHttpClient(HttpClient httpClient) : base(httpClient) { } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Altinn.ApiClients.Maskinporten/Services/SettingsX509ClientDefinition.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Security.Cryptography.X509Certificates; 4 | using System.Threading.Tasks; 5 | using Altinn.ApiClients.Maskinporten.Config; 6 | using Altinn.ApiClients.Maskinporten.Interfaces; 7 | using Altinn.ApiClients.Maskinporten.Models; 8 | 9 | namespace Altinn.ApiClients.Maskinporten.Services 10 | { 11 | public class SettingsX509ClientDefinition : IClientDefinition 12 | { 13 | public IMaskinportenSettings ClientSettings { get; set; } 14 | 15 | public async Task GetClientSecrets() 16 | { 17 | ClientSecrets clientSecrets = new ClientSecrets(); 18 | 19 | if (string.IsNullOrEmpty(ClientSettings.EncodedX509)) return clientSecrets; 20 | 21 | // See tip #5 at https://paulstovell.com/x509certificate2/ 22 | var file = Path.Combine(Path.GetTempPath(), "altinn-apiclient-maskinporten-" + Guid.NewGuid()); 23 | await File.WriteAllBytesAsync(file, Convert.FromBase64String(ClientSettings.EncodedX509)); 24 | try 25 | { 26 | clientSecrets.ClientCertificate = new X509Certificate2( 27 | file, 28 | String.Empty, 29 | X509KeyStorageFlags.EphemeralKeySet); 30 | 31 | return clientSecrets; 32 | } 33 | finally 34 | { 35 | File.Delete(file); 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /samples/SampleWebApp/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | }, 9 | "AllowedHosts": "*", 10 | 11 | "MaskinportenSettingsForSomeExternalApi": { 12 | "Environment": "ver2", 13 | "ClientId": "some-client-id", 14 | "Scope": "altinn:somescope", 15 | "EncodedJwk": "ewogICAic..." 16 | }, 17 | 18 | "MaskinportenSettingsForSomeOtherExternalApi": { 19 | "Environment": "ver2", 20 | "ClientId": "some-client-id", 21 | "Scope": "altinn:someotherscope", 22 | "EncodedJwk": "ewogICAic..." 23 | }, 24 | 25 | "MyCustomClientDefinition": { 26 | "Environment": "ver2", 27 | "ClientId": "some-client-id", 28 | "Scope": "altinn:somescope", 29 | "AzureKeyVaultName": "my-keyvault", 30 | "SecretName": "my-cert-with-private-key" 31 | }, 32 | 33 | "MaskinportenSettingsForX509Settings": { 34 | "Environment": "ver2", 35 | "ClientId": "some-client-id", 36 | "Scope": "altinn:somescope", 37 | "EncodedX509": "MIIWUU51RzhETDNqejVt..." 38 | }, 39 | 40 | "MyMaskinportenSettingsForThumbprint": { 41 | "Environment": "ver2", 42 | "ClientId": "some-client-id", 43 | "Scope": "altinn:somescope", 44 | "CertificateStoreThumbprint": "4325B22433984608AB5049103837F11C6BCA520D" 45 | }, 46 | 47 | "MyCustomMaskinportenSettingsForCertFile": { 48 | "Environment": "ver2", 49 | "ClientId": "some-client-id", 50 | "Scope": "altinn:somescope", 51 | "CertificateStoreThumbprint": "4325B22433984608AB5049103837F11C6BCA520D" 52 | } 53 | } -------------------------------------------------------------------------------- /src/Altinn.ApiClients.Maskinporten/Helpers/MaskinportenClientDefinitionHelper.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using Altinn.ApiClients.Maskinporten.Interfaces; 4 | 5 | namespace Altinn.ApiClients.Maskinporten.Helpers 6 | { 7 | /// 8 | /// Manage two lists with identifiers for instances of IClientDefinitions. The index of a given instance key 9 | /// in each of these lists are used by MaskinportenHttpMessageHandlerFactory to find the correct 10 | /// IClientDefinition instance in which to inject the correct settings. 11 | /// 12 | public static class MaskinportenClientDefinitionHelper 13 | { 14 | private static readonly List ClientDefinitionInstanceKeys = new(); 15 | private static readonly List Settings = new(); 16 | 17 | public static void AddClientDefinitionInstance(string clientDefinitionKey, IMaskinportenSettings settings) 18 | { 19 | ClientDefinitionInstanceKeys.Add(clientDefinitionKey); 20 | Settings.Add(settings); 21 | } 22 | 23 | public static int GetIndexOf(string clientDefinitionKey) 24 | { 25 | return ClientDefinitionInstanceKeys.IndexOf(clientDefinitionKey); 26 | } 27 | 28 | public static IMaskinportenSettings GetSettingsByIndex(int index) 29 | { 30 | return Settings.ElementAt(index); 31 | } 32 | 33 | public static string GetClientDefinitionKey() 34 | where TClient : class 35 | { 36 | return typeof(TClient).FullName; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Altinn.ApiClients.Maskinporten/Altinn.ApiClients.Maskinporten.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | 6 | 7 | 8 | Altinn.ApiClients.Maskinporten 9 | Digitaliseringsdirektoratet 10 | digdir;altinn;maskinporten 11 | 12 | This library is used for integrating with HTTP APIs secured with Maskinporten, transparently obtaining access tokens based on configured values 13 | 14 | git 15 | https://github.com/Altinn/altinn-apiclient-maskinporten 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/Altinn.ApiClients.Maskinporten/Altinn.ApiClients.Maskinporten.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.30717.126 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Altinn.ApiClients.Maskinporten", "Altinn.ApiClients.Maskinporten.csproj", "{160B361A-34E9-4B47-89BF-31641864FDD6}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SampleWebApp", "..\..\samples\SampleWebApp\SampleWebApp.csproj", "{DF814DED-CEE7-4F26-9ABB-EF26AD84E400}" 9 | EndProject 10 | Global 11 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 12 | Debug|Any CPU = Debug|Any CPU 13 | Release|Any CPU = Release|Any CPU 14 | EndGlobalSection 15 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 16 | {160B361A-34E9-4B47-89BF-31641864FDD6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 17 | {160B361A-34E9-4B47-89BF-31641864FDD6}.Debug|Any CPU.Build.0 = Debug|Any CPU 18 | {160B361A-34E9-4B47-89BF-31641864FDD6}.Release|Any CPU.ActiveCfg = Release|Any CPU 19 | {160B361A-34E9-4B47-89BF-31641864FDD6}.Release|Any CPU.Build.0 = Release|Any CPU 20 | {DF814DED-CEE7-4F26-9ABB-EF26AD84E400}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 21 | {DF814DED-CEE7-4F26-9ABB-EF26AD84E400}.Debug|Any CPU.Build.0 = Debug|Any CPU 22 | {DF814DED-CEE7-4F26-9ABB-EF26AD84E400}.Release|Any CPU.ActiveCfg = Release|Any CPU 23 | {DF814DED-CEE7-4F26-9ABB-EF26AD84E400}.Release|Any CPU.Build.0 = Release|Any CPU 24 | EndGlobalSection 25 | GlobalSection(SolutionProperties) = preSolution 26 | HideSolutionNode = FALSE 27 | EndGlobalSection 28 | GlobalSection(ExtensibilityGlobals) = postSolution 29 | SolutionGuid = {ED27A711-651F-44EF-BBE5-6F75BA073E31} 30 | EndGlobalSection 31 | EndGlobal 32 | -------------------------------------------------------------------------------- /src/Altinn.ApiClients.Maskinporten/Extensions/HttpClientBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Altinn.ApiClients.Maskinporten.Factories; 3 | using Altinn.ApiClients.Maskinporten.Interfaces; 4 | using Microsoft.Extensions.DependencyInjection; 5 | 6 | namespace Altinn.ApiClients.Maskinporten.Extensions 7 | { 8 | public static class HttpClientBuilderExtensions 9 | { 10 | public static IHttpClientBuilder AddMaskinportenHttpMessageHandler( 11 | this IHttpClientBuilder clientBuilder, string httpClientName, Action configureClientDefinition = null) 12 | where TClientDefinition : class, IClientDefinition 13 | { 14 | return clientBuilder.AddHttpMessageHandler(sp => 15 | { 16 | var maskinportenHttpMessageHandlerFactory = 17 | sp.GetRequiredService(); 18 | return maskinportenHttpMessageHandlerFactory.Get(httpClientName, configureClientDefinition); 19 | }); 20 | } 21 | 22 | public static IHttpClientBuilder AddMaskinportenHttpMessageHandler( 23 | this IHttpClientBuilder clientBuilder, Action configureClientDefinition = null) 24 | where TClientDefinition : class, IClientDefinition 25 | where THttpClient : class 26 | { 27 | return clientBuilder.AddHttpMessageHandler(sp => 28 | { 29 | var maskinportenHttpMessageHandlerFactory = 30 | sp.GetRequiredService(); 31 | return maskinportenHttpMessageHandlerFactory.Get(configureClientDefinition); 32 | }); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/ci-nuget.yml: -------------------------------------------------------------------------------- 1 | name: CI Nuget 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 15 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | - name: Set VERSION variable from latest tag (if existing) plus date and commit 17 | run: | 18 | LATEST_TAG=$(git ls-remote --tags origin | tail -n 1 | awk '{ print $2; }' | sed 's/.*v//') 19 | echo "LATEST_TAG: ${LATEST_TAG}" 20 | LATEST_TAG=${LATEST_TAG:=0.0.1} 21 | VERSION=${LATEST_TAG}-next.$(date +%Y%m%d).${GITHUB_RUN_NUMBER} 22 | echo "VERSION: ${VERSION}" 23 | echo "VERSION=${VERSION}" >> $GITHUB_ENV 24 | 25 | - name: Set SOLUTION variable to point to solution file 26 | run: | 27 | SOLUTION=$(find . -name '*.sln' -printf "%p" -quit) 28 | echo "SOLUTION: ${SOLUTION}" 29 | echo "SOLUTION=${SOLUTION}" >> $GITHUB_ENV 30 | 31 | - name: Build 32 | run: dotnet build --configuration Release /p:Version=${VERSION} ${SOLUTION} 33 | 34 | - name: Pack with debug symbols 35 | run: dotnet pack --configuration Release /p:Version=${VERSION} -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg --output . ${SOLUTION} 36 | 37 | - name: Upload artifact 38 | uses: actions/upload-artifact@v4 39 | with: 40 | name: package 41 | path: '*.*nupkg' 42 | 43 | push: 44 | needs: build 45 | runs-on: ubuntu-latest 46 | steps: 47 | 48 | - name: Download artifact 49 | uses: actions/download-artifact@v4 50 | with: 51 | name: package 52 | 53 | - name: Push to NuGet 54 | run: dotnet nuget push *.nupkg --source https://nuget.pkg.github.com/${GITHUB_REPOSITORY%/*}/index.json --api-key ${GITHUB_TOKEN} 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | -------------------------------------------------------------------------------- /src/Altinn.ApiClients.Maskinporten/Interfaces/IMaskinportenService.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Cryptography.X509Certificates; 2 | using System.Threading.Tasks; 3 | using Altinn.ApiClients.Maskinporten.Models; 4 | using Microsoft.IdentityModel.Tokens; 5 | 6 | namespace Altinn.ApiClients.Maskinporten.Interfaces 7 | { 8 | public interface IMaskinportenService 9 | { 10 | /// 11 | /// Generates a Maskinporten access token using a JsonWebKey 12 | /// 13 | Task GetToken(JsonWebKey jwk, string environment, string clientId, string scope, string resource, string consumerOrgNo = null, bool disableCaching = false); 14 | 15 | /// 16 | /// Generates a Maskinporten access token using a X509Certificate 17 | /// 18 | Task GetToken(X509Certificate2 cert, string environment, string clientId, string scope, string resource, string consumerOrgNo = null, bool disableCaching = false); 19 | 20 | /// 21 | /// Generates a Maskinporten access token using a base64encoded JsonWebKey 22 | /// 23 | Task GetToken(string base64EncodedJWK, string environment, string clientId, string scope, string resource, string consumerOrgNo = null, bool disableCaching = false); 24 | 25 | /// 26 | /// Generates a access token based on supplied definition containing settings and secrets. 27 | /// 28 | Task GetToken(IClientDefinition clientDefinition, bool disableCaching = false); 29 | 30 | /// 31 | /// Exchanges a Maskinporten access token to a Altinn token. 32 | /// 33 | Task ExchangeToAltinnToken(TokenResponse tokenResponse, string environment, string userName = null, string password = null, bool disableCaching = false, bool isTestOrg = false); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Altinn.ApiClients.Maskinporten/Services/CertificateStoreClientDefinition.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Security.Cryptography.X509Certificates; 3 | using System.Threading.Tasks; 4 | using Altinn.ApiClients.Maskinporten.Config; 5 | using Altinn.ApiClients.Maskinporten.Interfaces; 6 | using Altinn.ApiClients.Maskinporten.Models; 7 | 8 | namespace Altinn.ApiClients.Maskinporten.Services 9 | { 10 | public class CertificateStoreClientDefinition : IClientDefinition 11 | { 12 | public IMaskinportenSettings ClientSettings { get; set; } 13 | 14 | public Task GetClientSecrets() 15 | { 16 | X509Certificate2 signingCertificate = GetCertificateFromKeyStore(ClientSettings.CertificateStoreThumbprint, StoreName.My, StoreLocation.LocalMachine); 17 | return Task.FromResult(new ClientSecrets() 18 | { 19 | ClientCertificate = signingCertificate 20 | }); 21 | } 22 | 23 | private static X509Certificate2 GetCertificateFromKeyStore(string thumbprint, StoreName storeName, StoreLocation storeLocation, bool onlyValid = false) 24 | { 25 | var store = new X509Store(storeName, storeLocation); 26 | store.Open(OpenFlags.ReadOnly); 27 | var certCollection = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, onlyValid); 28 | var enumerator = certCollection.GetEnumerator(); 29 | X509Certificate2 cert = null; 30 | while (enumerator.MoveNext()) 31 | { 32 | cert = enumerator.Current; 33 | } 34 | 35 | if (cert == null) 36 | { 37 | throw new ArgumentException("Unable to find certificate in store with thumbprint: " + thumbprint + ". Check your config, and make sure the certificate is installed in the \"LocalMachine\\My\" store."); 38 | } 39 | 40 | return cert; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /samples/SampleWebApp/MyCustomClientDefinition.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Security.Cryptography.X509Certificates; 3 | using System.Threading.Tasks; 4 | using Altinn.ApiClients.Maskinporten.Interfaces; 5 | using Altinn.ApiClients.Maskinporten.Models; 6 | using Azure.Security.KeyVault.Secrets; 7 | using Azure.Identity; 8 | using Microsoft.Extensions.Logging; 9 | using SampleWebApp.Config; 10 | 11 | namespace SampleWebApp 12 | { 13 | public class MyCustomClientDefinition : IClientDefinition 14 | { 15 | private readonly ILogger _logger; 16 | public IMaskinportenSettings ClientSettings { get; set; } 17 | private ClientSecrets _clientSecrets; 18 | 19 | public MyCustomClientDefinition(ILogger logger) 20 | { 21 | _logger = logger; 22 | } 23 | 24 | public async Task GetClientSecrets() 25 | { 26 | if (_clientSecrets != null) 27 | { 28 | return _clientSecrets; 29 | } 30 | 31 | _logger.LogInformation("Getting secrets from Azure"); 32 | 33 | var myCustomClientDefinitionSettings = (MyCustomClientDefinitionSettings)ClientSettings; 34 | 35 | var secretClient = new SecretClient( 36 | new Uri($"https://{myCustomClientDefinitionSettings.AzureKeyVaultName}.vault.azure.net/"), 37 | new DefaultAzureCredential()); 38 | 39 | var secret = await secretClient.GetSecretAsync(myCustomClientDefinitionSettings.SecretName); 40 | var base64Str = secret.HasValue ? secret.Value.Value : null; 41 | if (base64Str == null) 42 | { 43 | throw new ApplicationException("Unable to fetch cert from key vault"); 44 | } 45 | 46 | var signingCertificate = new X509Certificate2( 47 | (ReadOnlySpan)Convert.FromBase64String(base64Str), 48 | ClientSettings.CertificatePkcs12Password, 49 | X509KeyStorageFlags.EphemeralKeySet); 50 | 51 | _clientSecrets = new ClientSecrets() 52 | { 53 | ClientCertificate = signingCertificate 54 | }; 55 | 56 | return _clientSecrets; 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /.github/workflows/release-nuget.yml: -------------------------------------------------------------------------------- 1 | # Workflow creating a Github package 2 | name: Release Nuget 3 | 4 | on: 5 | workflow_dispatch: 6 | push: 7 | tags: 8 | - "v[0-9]+.[0-9]+.[0-9]+.*" 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 15 13 | outputs: 14 | version: ${{ steps.set_version.outputs.version }} 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | - name: Setup build environment 20 | uses: actions/setup-dotnet@v4 21 | with: 22 | dotnet-version: '6.0.x' 23 | - name: Set VERSION variable from latest tag 24 | id: set_version 25 | run: | 26 | LATEST_TAG=$(git fetch --tags && git for-each-ref --sort=creatordate --format '%(refname)' refs/tags | tail -n 1) 27 | if [[ "${LATEST_TAG}" == "" ]]; then echo "Unable to determine latest tag! Exiting"; exit 1; fi 28 | echo "LATEST_TAG: ${LATEST_TAG}" 29 | VERSION=${LATEST_TAG/refs\/tags\/v/} 30 | echo "VERSION: ${VERSION}" 31 | echo "VERSION=${VERSION}" >> $GITHUB_ENV 32 | echo "::set-output name=version::${VERSION}" 33 | - name: Set PROJECT variable to point to solution file 34 | run: | 35 | PROJECT=$(find . -name 'Altinn.ApiClients.Maskinporten.csproj' -printf "%p" -quit) 36 | echo "PROJECT=${PROJECT}" >> $GITHUB_ENV 37 | - name: Build 38 | run: dotnet build --configuration Release /p:Version=${VERSION} ${PROJECT} 39 | - name: Pack with debug symbols 40 | run: dotnet pack --configuration Release /p:Version=${VERSION} -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg --output . ${PROJECT} 41 | - name: Upload artifact 42 | uses: actions/upload-artifact@v4 43 | with: 44 | name: package 45 | path: '*.*nupkg' 46 | push-to-nuget: 47 | needs: build 48 | runs-on: ubuntu-latest 49 | steps: 50 | - name: Download artifact 51 | uses: actions/download-artifact@v4 52 | with: 53 | name: package 54 | - name: Push to nuget.org 55 | env: 56 | NUGET_ORG_API_KEY: ${{secrets.NUGET_ORG_API_KEY}} 57 | run: dotnet nuget push *.${{ needs.build.outputs.version }}.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_ORG_API_KEY }} 58 | push-to-github: 59 | needs: build 60 | runs-on: ubuntu-latest 61 | steps: 62 | - name: Download artifact 63 | uses: actions/download-artifact@v4 64 | with: 65 | name: package 66 | - name: Push to Github Packages 67 | run: dotnet nuget push *.nupkg --source https://nuget.pkg.github.com/${GITHUB_REPOSITORY%/*}/index.json --api-key ${GITHUB_TOKEN} 68 | env: 69 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 70 | -------------------------------------------------------------------------------- /src/Altinn.ApiClients.Maskinporten/Handlers/MaskinportenTokenHandler.cs: -------------------------------------------------------------------------------- 1 | using Altinn.ApiClients.Maskinporten.Models; 2 | using System.Net; 3 | using System.Net.Http; 4 | using System.Net.Http.Headers; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using Altinn.ApiClients.Maskinporten.Interfaces; 8 | 9 | namespace Altinn.ApiClients.Maskinporten.Handlers 10 | { 11 | public class MaskinportenTokenHandler : DelegatingHandler 12 | { 13 | private readonly IMaskinportenService _maskinporten; 14 | private readonly IClientDefinition _clientDefinition; 15 | 16 | public MaskinportenTokenHandler(IMaskinportenService maskinporten, IClientDefinition clientDefinition) 17 | { 18 | _maskinporten = maskinporten; 19 | _clientDefinition = clientDefinition; 20 | } 21 | 22 | protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) 23 | { 24 | TokenResponse tokenResponse; 25 | if (request.Headers.Authorization == null || 26 | (_clientDefinition.ClientSettings.OverwriteAuthorizationHeader.HasValue && 27 | _clientDefinition.ClientSettings.OverwriteAuthorizationHeader.Value)) 28 | { 29 | tokenResponse = await GetTokenResponse(cancellationToken); 30 | if (tokenResponse != null) 31 | { 32 | request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokenResponse.AccessToken); 33 | } 34 | } 35 | 36 | HttpResponseMessage response = await base.SendAsync(request, cancellationToken); 37 | 38 | if (response.StatusCode != HttpStatusCode.Unauthorized) 39 | { 40 | return response; 41 | } 42 | 43 | tokenResponse = await RefreshTokenResponse(cancellationToken); 44 | if (tokenResponse != null) 45 | { 46 | request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokenResponse.AccessToken); 47 | response = await base.SendAsync(request, cancellationToken); 48 | } 49 | 50 | return response; 51 | } 52 | 53 | private async Task GetTokenResponse(CancellationToken cancellationToken) 54 | { 55 | if (cancellationToken.IsCancellationRequested) return null; 56 | TokenResponse tokenResponse = await _maskinporten.GetToken(_clientDefinition); 57 | return tokenResponse; 58 | } 59 | 60 | private async Task RefreshTokenResponse(CancellationToken cancellationToken) 61 | { 62 | if (cancellationToken.IsCancellationRequested) return null; 63 | TokenResponse tokenResponse = await _maskinporten.GetToken(_clientDefinition, true); 64 | return tokenResponse; 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /samples/SampleWebApp/Controllers/MaskinportenTestController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using System.Net.Http; 3 | using System.Threading.Tasks; 4 | 5 | namespace SampleWebApp.Controllers 6 | { 7 | [ApiController] 8 | [Route("[controller]")] 9 | public class MaskinportenTestController : ControllerBase 10 | { 11 | private readonly MyMaskinportenHttpClient _myMaskinportenHttpClient; 12 | private readonly IMyOtherMaskinportenHttpClient _myOtherMaskinportenHttpClient; 13 | private readonly MyThirdMaskinportenHttpClient _myThirdMaskinportenHttpClient; 14 | private readonly MyFourthMaskinportenHttpClient _myFourthMaskinportenHttpClient; 15 | 16 | private readonly IHttpClientFactory _clientFactory; 17 | 18 | public MaskinportenTestController( 19 | MyMaskinportenHttpClient myMaskinportenHttpClient, 20 | IMyOtherMaskinportenHttpClient myOtherMaskinportenHttpClient, 21 | MyThirdMaskinportenHttpClient myThirdMaskinportenHttpClient, 22 | MyFourthMaskinportenHttpClient myFourthMaskinportenHttpClient, 23 | IHttpClientFactory clientFactory) 24 | { 25 | // These are the injected typed client created in Startup.cs 26 | _myMaskinportenHttpClient = myMaskinportenHttpClient; 27 | _myOtherMaskinportenHttpClient = myOtherMaskinportenHttpClient; 28 | _myThirdMaskinportenHttpClient = myThirdMaskinportenHttpClient; 29 | _myFourthMaskinportenHttpClient = myFourthMaskinportenHttpClient; 30 | 31 | // Get the factory as well so we can get our named clients 32 | _clientFactory = clientFactory; 33 | } 34 | 35 | [HttpGet] 36 | public async Task Get() 37 | { 38 | // You can use something like https://requestbin.com to see what headers are sent 39 | var url = "https://eosmgovlpmlz4lx.m.pipedream.net"; 40 | /* 41 | // Here we instantiate a named client as defined in Startup.cs 42 | var client0 = _clientFactory.CreateClient("myhttpclient1"); 43 | var result0 = await client0.GetAsync(url + "?myhttpclient"); 44 | 45 | 46 | var client1 = _clientFactory.CreateClient("myhttpclient2"); 47 | var result1 = await client1.GetAsync(url + "?myhttpclient2"); 48 | 49 | // Perform some requests with both the named client and the type client. This will both use the same token. 50 | 51 | var result2 = await _myMaskinportenHttpClient.PerformStuff(url + "?_myMaskinportenHttpClient"); 52 | var result3 = await _myOtherMaskinportenHttpClient.PerformStuff(url + "?_myOtherMaskinportenHttpClient"); 53 | var result4 = await _myThirdMaskinportenHttpClient.PerformStuff(url + "?_myThirdMaskinportenHttpClient");*/ 54 | 55 | var result2 = await _myMaskinportenHttpClient.PerformStuff(url + "?_myMaskinportenHttpClient"); 56 | var result5 = await _myFourthMaskinportenHttpClient.PerformStuff(url + "?_myFourthMaskinportenHttpClient"); 57 | 58 | return "Done!"; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Altinn.ApiClients.Maskinporten/Factories/MaskinportenHttpMessageHandlerFactory.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Net.Http; 5 | using Altinn.ApiClients.Maskinporten.Handlers; 6 | using Altinn.ApiClients.Maskinporten.Helpers; 7 | using Altinn.ApiClients.Maskinporten.Interfaces; 8 | 9 | namespace Altinn.ApiClients.Maskinporten.Factories 10 | { 11 | /// 12 | /// This factory will have all instances of IClientDefinition injected. Using MaskinportenClientDefinitionHelper to 13 | /// get the correct index for a given named/typed client, the factory will populate the correct IClientDefinition 14 | /// instance with the config, and return a MaskinportenTokenHandler which is attached to the named/typed client 15 | /// 16 | public class MaskinportenHttpMessageHandlerFactory 17 | { 18 | private readonly IEnumerable _clientDefinitions; 19 | private readonly IMaskinportenService _maskinportenService; 20 | 21 | public MaskinportenHttpMessageHandlerFactory(IEnumerable clientDefinitions, 22 | IMaskinportenService maskinportenService) 23 | { 24 | _clientDefinitions = clientDefinitions; 25 | _maskinportenService = maskinportenService; 26 | } 27 | 28 | public DelegatingHandler Get(Action configureClientDefinition = null) 29 | where TClientDefinition : class, IClientDefinition 30 | where THttpClient : class 31 | where THttpClientImplementation : class, THttpClient 32 | { 33 | return GetByKey(MaskinportenClientDefinitionHelper.GetClientDefinitionKey(), configureClientDefinition); 34 | } 35 | 36 | public DelegatingHandler Get(Action configureClientDefinition = null) 37 | where TClientDefinition : class, IClientDefinition 38 | where THttpClient : class 39 | { 40 | return GetByKey(MaskinportenClientDefinitionHelper.GetClientDefinitionKey(), configureClientDefinition); 41 | } 42 | 43 | public DelegatingHandler Get(string httpClientName, Action configureClientDefinition = null) 44 | where TClientDefinition : class, IClientDefinition 45 | { 46 | return GetByKey(httpClientName, configureClientDefinition); 47 | } 48 | 49 | private DelegatingHandler GetByKey(string key, Action configureClientDefinition = null) 50 | where TClientDefinition : class, IClientDefinition 51 | { 52 | var index = MaskinportenClientDefinitionHelper.GetIndexOf(key); 53 | var clientDefinition = (TClientDefinition)_clientDefinitions.ElementAt(index); 54 | clientDefinition.ClientSettings = MaskinportenClientDefinitionHelper.GetSettingsByIndex(index); 55 | configureClientDefinition?.Invoke(clientDefinition); 56 | return new MaskinportenTokenHandler(_maskinportenService, clientDefinition); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Altinn.ApiClients.Maskinporten/Interfaces/IMaskinportenSettings.cs: -------------------------------------------------------------------------------- 1 | namespace Altinn.ApiClients.Maskinporten.Interfaces; 2 | 3 | public interface IMaskinportenSettings 4 | { 5 | /// 6 | /// ClientID to use 7 | /// 8 | string ClientId { get; set; } 9 | 10 | /// 11 | /// Scopes to request. Must be provisioned on the supplied client. 12 | /// 13 | string Scope { get; set; } 14 | 15 | /// 16 | /// Resource claim for assertion. This will be the `aud`-claim in the received access token 17 | /// 18 | string Resource { get; set; } 19 | 20 | /// 21 | /// The Maskinporten environment. Valid values are ver1, ver2, test or prod 22 | /// 23 | string Environment { get; set; } 24 | 25 | /// 26 | /// Path to X.509 certificate with private key in PKCS#12-file 27 | /// 28 | string CertificatePkcs12Path { get; set; } 29 | 30 | /// 31 | /// Secret to X.509 certificate with private key in PKCS#12-file 32 | /// 33 | string CertificatePkcs12Password { get; set; } 34 | 35 | /// 36 | /// Thumbprint for cert in local machine certificate store (Windows only) 37 | /// 38 | string CertificateStoreThumbprint { get; set; } 39 | 40 | /// 41 | /// Base64 Encoded Json Web Key 42 | /// 43 | string EncodedJwk { get; set; } 44 | 45 | /// 46 | /// Base64 Encoded X509 certificate with private key 47 | /// 48 | string EncodedX509 { get; set; } 49 | 50 | /// 51 | /// Optional. Consumer organization number for Maskinporten-based delegations 52 | /// 53 | string ConsumerOrgNo { get; set; } 54 | 55 | /// 56 | /// Optional. Enterprise username for token enrichment 57 | /// 58 | string EnterpriseUserName { get; set; } 59 | 60 | /// 61 | /// Optional. Enterprise password for token enrichment 62 | /// 63 | string EnterpriseUserPassword { get; set; } 64 | 65 | /// 66 | /// Optional. Enables Altinn token exchange without enterprise user authentication. Ignored if EnterpriseUserName/Password is supplied (which implies token exchange). 67 | /// 68 | bool? ExhangeToAltinnToken { get; set; } 69 | 70 | /// 71 | /// Optional. The Altinn Token Exchange environment. Valid values are prod, tt02, at21, at22, at23, at24. Default: derive from 'Environment' 72 | /// 73 | string TokenExchangeEnvironment { get; set; } 74 | 75 | /// 76 | /// Optional. Enables the DigDir token to be exchanged into a Altinn token for the test organisation. 77 | /// 78 | bool? UseAltinnTestOrg { get; set; } 79 | 80 | /// 81 | /// Optional. Enabels verbose logging that should only be enabled when troubleshooting. Will cause logging (with severity "Information") of assertions. 82 | /// 83 | bool? EnableDebugLogging { get; set; } 84 | 85 | /// 86 | /// Optional. Overwrites existing Authorization-header if set. Default: ignore existing Authorization-header. 87 | /// 88 | bool? OverwriteAuthorizationHeader { get; set; } 89 | } 90 | -------------------------------------------------------------------------------- /src/Altinn.ApiClients.Maskinporten/Services/FileTokenCacheProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Text; 6 | using System.Text.Json; 7 | using System.Threading.Tasks; 8 | using Altinn.ApiClients.Maskinporten.Interfaces; 9 | using Altinn.ApiClients.Maskinporten.Models; 10 | 11 | namespace Altinn.ApiClients.Maskinporten.Services 12 | { 13 | public class FileTokenCacheProvider : ITokenCacheProvider 14 | { 15 | private readonly string _pathToCacheFile; 16 | private static Dictionary _tokenCacheStoreEntries = new(); 17 | 18 | public FileTokenCacheProvider(string pathToCacheFile) 19 | { 20 | _pathToCacheFile = pathToCacheFile; 21 | } 22 | 23 | public FileTokenCacheProvider() : this(Path.GetTempPath() + ".maskinportenTokenCache.json") {} 24 | 25 | public async Task<(bool success, TokenResponse result)> TryGetToken(string key) 26 | { 27 | if (_tokenCacheStoreEntries.Count == 0) 28 | { 29 | await LoadTokenCacheStore(); 30 | } 31 | 32 | if (_tokenCacheStoreEntries.TryGetValue(key, out TokenCacheStoreEntry cachedTokenEntry)) 33 | { 34 | if (cachedTokenEntry.Expires > DateTime.UtcNow) 35 | { 36 | return (true, cachedTokenEntry.TokenResponse); 37 | } 38 | } 39 | 40 | return (false, null); 41 | } 42 | 43 | public async Task Set(string key, TokenResponse value, TimeSpan timeToLive) 44 | { 45 | _tokenCacheStoreEntries[key] = new TokenCacheStoreEntry 46 | { 47 | TokenResponse = value, 48 | Expires = DateTime.UtcNow.Add(timeToLive) 49 | }; 50 | 51 | await WriteTokenCacheStore(); 52 | } 53 | 54 | private async Task LoadTokenCacheStore() 55 | { 56 | byte[] fileContents; 57 | await using (FileStream fs = File.Open(_pathToCacheFile, FileMode.OpenOrCreate, FileAccess.ReadWrite)) 58 | { 59 | if (fs.Length == 0) 60 | { 61 | return; 62 | } 63 | 64 | fileContents = new byte[fs.Length]; 65 | // ReSharper disable once MustUseReturnValue 66 | await fs.ReadAsync(fileContents.AsMemory(0, (int)fs.Length)); 67 | } 68 | 69 | _tokenCacheStoreEntries = JsonSerializer.Deserialize>(fileContents); 70 | } 71 | 72 | private async Task WriteTokenCacheStore() 73 | { 74 | // Remove all expired entries before writing 75 | foreach (var tokenCacheStoreEntry in 76 | _tokenCacheStoreEntries.Where(tokenCacheStoreEntry => tokenCacheStoreEntry.Value.Expires < DateTime.UtcNow)) 77 | { 78 | _tokenCacheStoreEntries.Remove(tokenCacheStoreEntry.Key); 79 | } 80 | 81 | await using FileStream fs = File.Open(_pathToCacheFile, FileMode.OpenOrCreate, FileAccess.ReadWrite); 82 | fs.SetLength(0); 83 | await fs.WriteAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(_tokenCacheStoreEntries))); 84 | await fs.FlushAsync(); 85 | } 86 | } 87 | 88 | internal class TokenCacheStoreEntry 89 | { 90 | public TokenResponse TokenResponse; 91 | public DateTime Expires; 92 | } 93 | } -------------------------------------------------------------------------------- /src/Altinn.ApiClients.Maskinporten/Config/MaskinportenSettings.cs: -------------------------------------------------------------------------------- 1 | using Altinn.ApiClients.Maskinporten.Interfaces; 2 | 3 | namespace Altinn.ApiClients.Maskinporten.Config 4 | { 5 | public class MaskinportenSettings : IMaskinportenSettings 6 | { 7 | /// 8 | /// ClientID to use 9 | /// 10 | public string ClientId { get; set; } 11 | 12 | /// 13 | /// Scopes to request. Must be provisioned on the supplied client. 14 | /// 15 | public string Scope { get; set; } 16 | 17 | /// 18 | /// Resource claim for assertion. This will be the `aud`-claim in the received access token 19 | /// 20 | public string Resource { get; set; } 21 | 22 | /// 23 | /// The Maskinporten environment. Valid values are ver1, ver2, test or prod 24 | /// 25 | public string Environment { get; set; } 26 | 27 | /// 28 | /// Path to X.509 certificate with private key in PKCS#12-file 29 | /// 30 | public string CertificatePkcs12Path { get; set; } 31 | 32 | /// 33 | /// Secret to X.509 certificate with private key in PKCS#12-file 34 | /// 35 | public string CertificatePkcs12Password { get; set; } 36 | 37 | /// 38 | /// Thumbprint for cert in local machine certificate store (Windows only) 39 | /// 40 | public string CertificateStoreThumbprint { get; set; } 41 | 42 | /// 43 | /// Base64 Encoded Json Web Key 44 | /// 45 | public string EncodedJwk { get; set; } 46 | 47 | /// 48 | /// Base64 Encoded X509 certificate with private key 49 | /// 50 | public string EncodedX509 { get; set; } 51 | 52 | /// 53 | /// Optional. Consumer organization number for Maskinporten-based delegations 54 | /// 55 | public string ConsumerOrgNo { get; set; } 56 | 57 | /// 58 | /// Optional. Enterprise username for token enrichment 59 | /// 60 | public string EnterpriseUserName { get; set; } 61 | 62 | /// 63 | /// Optional. Enterprise password for token enrichment 64 | /// 65 | public string EnterpriseUserPassword { get; set; } 66 | 67 | /// 68 | /// Optional. Enables Altinn token exchange without enterprise user authentication. Ignored if EnterpriseUserName/Password is supplied (which implies token exchange). 69 | /// 70 | public bool? ExhangeToAltinnToken { get; set; } 71 | 72 | /// 73 | /// Optional. The Altinn Token Exchange environment. Valid values are prod, tt02, at21, at22, at23, at24. Default: derive from 'Environment' 74 | /// 75 | public string TokenExchangeEnvironment { get; set; } 76 | 77 | /// 78 | /// Optional. Enables the DigDir token to be exchanged into a Altinn token for the test organisation. 79 | /// 80 | public bool? UseAltinnTestOrg { get; set; } 81 | 82 | /// 83 | /// Optional. Enabels verbose logging that should only be enabled when troubleshooting. Will cause logging (with severity "Information") of assertions. 84 | /// 85 | public bool? EnableDebugLogging { get; set; } 86 | 87 | /// 88 | /// Optional. Overwrites existing Authorization-header if set. Default: ignore existing Authorization-header. 89 | /// 90 | public bool? OverwriteAuthorizationHeader { get; set; } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /samples/SampleWebApp/Startup.cs: -------------------------------------------------------------------------------- 1 | using Altinn.ApiClients.Maskinporten.Config; 2 | using Microsoft.AspNetCore.Builder; 3 | using Microsoft.AspNetCore.Hosting; 4 | using Microsoft.Extensions.Configuration; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using Microsoft.Extensions.Hosting; 7 | using Altinn.ApiClients.Maskinporten.Interfaces; 8 | using Altinn.ApiClients.Maskinporten.Services; 9 | using Altinn.ApiClients.Maskinporten.Extensions; 10 | using SampleWebApp.Config; 11 | 12 | namespace SampleWebApp 13 | { 14 | public class Startup 15 | { 16 | public Startup(IConfiguration configuration) 17 | { 18 | Configuration = configuration; 19 | } 20 | 21 | public IConfiguration Configuration { get; } 22 | 23 | // This method gets called by the runtime. Use this method to add services to the container. 24 | public void ConfigureServices(IServiceCollection services) 25 | { 26 | services.AddControllers(); 27 | 28 | // Explicitly add a file based token cache store. You could add your own by implementing ITokenCacheProvider. 29 | // This will place a file called ".maskinportenTokenCache.json" in the users temp-folder. This will NOT be deleted 30 | // after application termination. This makes it suitable for CLI usage. 31 | // 32 | // If no token cache store is added before the first AddMaskinportenHttpClient-call, a MemoryCache-based cache store 33 | // will be used. 34 | services.AddSingleton(); 35 | 36 | // Specifying separate HttpClient type and HttpClient implementation type, and passing IConfiguration instance which will be bound to an instance of MaskinportenSettings 37 | services.AddMaskinportenHttpClient( 38 | Configuration.GetSection("MaskinportenSettingsForSomeExternalApi")); 39 | 40 | // Specifying separat client type and client implementation type is optional. If you don't specify the client implementation type, the client type will be used as implementation type. 41 | // This uses AddHttpClient() under the hood. 42 | services.AddMaskinportenHttpClient( 43 | Configuration.GetSection("MaskinportenSettingsForSomeExternalApi")); 44 | 45 | // Using a typed HttpClient, and binding the MaskinportenSettings ourselves 46 | var maskinportenSettingsForSomeExternalApi = new MaskinportenSettings(); 47 | Configuration.GetSection("MaskinportenSettingsForSomeExternalApi").Bind(maskinportenSettingsForSomeExternalApi); 48 | services.AddMaskinportenHttpClient(maskinportenSettingsForSomeExternalApi); 49 | 50 | // If you need to access multiple APIs requiring different settings (ie. scopes) you must supply a unique combination of client type and client definition type. 51 | services.AddMaskinportenHttpClient( 52 | Configuration.GetSection("MaskinportenSettingsForSomeOtherExternalApi")); 53 | 54 | // You can reuse application settings for the across different HTTP clients, but override specific settings 55 | services.AddMaskinportenHttpClient( 56 | Configuration.GetSection("MaskinportenSettingsForSomeOtherExternalApi"), clientDefinition => 57 | { 58 | clientDefinition.ClientSettings.ExhangeToAltinnToken = true; 59 | clientDefinition.ClientSettings.Scope = 60 | "altinn:serviceowner/instances.read altinn:serviceowner/instances.write"; 61 | }); 62 | 63 | // As an alternative, named HTTP clients can be used. 64 | services.AddMaskinportenHttpClient("myhttpclient1", maskinportenSettingsForSomeExternalApi); 65 | services.AddMaskinportenHttpClient("myhttpclient2", Configuration.GetSection("MaskinportenSettingsForSomeExternalApi")); 66 | 67 | // Overloads are provided to send in a IConfiguration instance directly. This will be bound to an instance of MaskinportenSettings. 68 | services.AddMaskinportenHttpClient("myhttpclient3", 69 | Configuration.GetSection("MaskinportenSettingsForSomeExternalApi")); 70 | 71 | // You can also define your own client definitions with custom settings, which should inherit MaskinportenSettings (or implement IMaskinportenSettings) 72 | var myCustomClientDefinitionSettings = new MyCustomClientDefinitionSettings(); 73 | Configuration.GetSection("MyCustomClientDefinition").Bind(myCustomClientDefinitionSettings); 74 | services.AddMaskinportenHttpClient(myCustomClientDefinitionSettings); 75 | 76 | // Named http clients for custom client definitions 77 | services.AddMaskinportenHttpClient("myhttpclient4", myCustomClientDefinitionSettings); 78 | 79 | // You can chain additional handlers or configure the client further if you want 80 | /* 81 | services.AddMaskinportenHttpClient(maskinportenSettingsForSomeExternalApi) 82 | .AddHttpMessageHandler(sp => ...) 83 | .ConfigureHttpClient(client => ...) 84 | */ 85 | 86 | /* 87 | // Register a client definition and a configuration, identified by some arbitrary string key 88 | services.RegisterMaskinportenClientDefinition("my-client-definition-key", maskinportenSettingsForSomeExternalApi); 89 | 90 | // This can then be added as a HttpMessageHandler to any IClientBuilder (if also using DAN, Polly, Refit etc) 91 | services.AddHttpClient() 92 | .AddMaskinportenHttpMessageHandler("my-client-definition-key"); 93 | */ 94 | } 95 | 96 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env) 97 | { 98 | if (env.IsDevelopment()) 99 | { 100 | app.UseDeveloperExceptionPage(); 101 | } 102 | 103 | app.UseHttpsRedirection(); 104 | 105 | app.UseRouting(); 106 | 107 | app.UseAuthorization(); 108 | 109 | app.UseEndpoints(endpoints => 110 | { 111 | endpoints.MapControllers(); 112 | }); 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # JetBrains IDEs 7 | .idea/ 8 | 9 | # macOS 10 | .DS_Store 11 | 12 | # Secrets 13 | Certs/ 14 | 15 | # User-specific files 16 | *.rsuser 17 | *.suo 18 | *.user 19 | *.userosscache 20 | *.sln.docstates 21 | 22 | # User-specific files (MonoDevelop/Xamarin Studio) 23 | *.userprefs 24 | 25 | # Mono auto generated files 26 | mono_crash.* 27 | 28 | # Build results 29 | [Dd]ebug/ 30 | [Dd]ebugPublic/ 31 | [Rr]elease/ 32 | [Rr]eleases/ 33 | x64/ 34 | x86/ 35 | [Aa][Rr][Mm]/ 36 | [Aa][Rr][Mm]64/ 37 | bld/ 38 | [Bb]in/ 39 | [Oo]bj/ 40 | [Ll]og/ 41 | [Ll]ogs/ 42 | 43 | # Visual Studio 2015/2017 cache/options directory 44 | .vs/ 45 | # Uncomment if you have tasks that create the project's static files in wwwroot 46 | #wwwroot/ 47 | 48 | # Visual Studio 2017 auto generated files 49 | Generated\ Files/ 50 | 51 | # MSTest test Results 52 | [Tt]est[Rr]esult*/ 53 | [Bb]uild[Ll]og.* 54 | 55 | # NUnit 56 | *.VisualState.xml 57 | TestResult.xml 58 | nunit-*.xml 59 | 60 | # Build Results of an ATL Project 61 | [Dd]ebugPS/ 62 | [Rr]eleasePS/ 63 | dlldata.c 64 | 65 | # Benchmark Results 66 | BenchmarkDotNet.Artifacts/ 67 | 68 | # .NET Core 69 | project.lock.json 70 | project.fragment.lock.json 71 | artifacts/ 72 | 73 | # StyleCop 74 | StyleCopReport.xml 75 | 76 | # Files built by Visual Studio 77 | *_i.c 78 | *_p.c 79 | *_h.h 80 | *.ilk 81 | *.meta 82 | *.obj 83 | *.iobj 84 | *.pch 85 | *.pdb 86 | *.ipdb 87 | *.pgc 88 | *.pgd 89 | *.rsp 90 | *.sbr 91 | *.tlb 92 | *.tli 93 | *.tlh 94 | *.tmp 95 | *.tmp_proj 96 | *_wpftmp.csproj 97 | *.log 98 | *.vspscc 99 | *.vssscc 100 | .builds 101 | *.pidb 102 | *.svclog 103 | *.scc 104 | 105 | # Chutzpah Test files 106 | _Chutzpah* 107 | 108 | # Visual C++ cache files 109 | ipch/ 110 | *.aps 111 | *.ncb 112 | *.opendb 113 | *.opensdf 114 | *.sdf 115 | *.cachefile 116 | *.VC.db 117 | *.VC.VC.opendb 118 | 119 | # Visual Studio profiler 120 | *.psess 121 | *.vsp 122 | *.vspx 123 | *.sap 124 | 125 | # Visual Studio Trace Files 126 | *.e2e 127 | 128 | # TFS 2012 Local Workspace 129 | $tf/ 130 | 131 | # Guidance Automation Toolkit 132 | *.gpState 133 | 134 | # ReSharper is a .NET coding add-in 135 | _ReSharper*/ 136 | *.[Rr]e[Ss]harper 137 | *.DotSettings.user 138 | 139 | # TeamCity is a build add-in 140 | _TeamCity* 141 | 142 | # DotCover is a Code Coverage Tool 143 | *.dotCover 144 | 145 | # AxoCover is a Code Coverage Tool 146 | .axoCover/* 147 | !.axoCover/settings.json 148 | 149 | # Visual Studio code coverage results 150 | *.coverage 151 | *.coveragexml 152 | 153 | # NCrunch 154 | _NCrunch_* 155 | .*crunch*.local.xml 156 | nCrunchTemp_* 157 | 158 | # MightyMoose 159 | *.mm.* 160 | AutoTest.Net/ 161 | 162 | # Web workbench (sass) 163 | .sass-cache/ 164 | 165 | # Installshield output folder 166 | [Ee]xpress/ 167 | 168 | # DocProject is a documentation generator add-in 169 | DocProject/buildhelp/ 170 | DocProject/Help/*.HxT 171 | DocProject/Help/*.HxC 172 | DocProject/Help/*.hhc 173 | DocProject/Help/*.hhk 174 | DocProject/Help/*.hhp 175 | DocProject/Help/Html2 176 | DocProject/Help/html 177 | 178 | # Click-Once directory 179 | publish/ 180 | 181 | # Publish Web Output 182 | *.[Pp]ublish.xml 183 | *.azurePubxml 184 | # Note: Comment the next line if you want to checkin your web deploy settings, 185 | # but database connection strings (with potential passwords) will be unencrypted 186 | *.pubxml 187 | *.publishproj 188 | 189 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 190 | # checkin your Azure Web App publish settings, but sensitive information contained 191 | # in these scripts will be unencrypted 192 | PublishScripts/ 193 | 194 | # NuGet Packages 195 | *.nupkg 196 | # NuGet Symbol Packages 197 | *.snupkg 198 | # The packages folder can be ignored because of Package Restore 199 | **/[Pp]ackages/* 200 | # except build/, which is used as an MSBuild target. 201 | !**/[Pp]ackages/build/ 202 | # Uncomment if necessary however generally it will be regenerated when needed 203 | #!**/[Pp]ackages/repositories.config 204 | # NuGet v3's project.json files produces more ignorable files 205 | *.nuget.props 206 | *.nuget.targets 207 | 208 | # Microsoft Azure Build Output 209 | csx/ 210 | *.build.csdef 211 | 212 | # Microsoft Azure Emulator 213 | ecf/ 214 | rcf/ 215 | 216 | # Windows Store app package directories and files 217 | AppPackages/ 218 | BundleArtifacts/ 219 | Package.StoreAssociation.xml 220 | _pkginfo.txt 221 | *.appx 222 | *.appxbundle 223 | *.appxupload 224 | 225 | # Visual Studio cache files 226 | # files ending in .cache can be ignored 227 | *.[Cc]ache 228 | # but keep track of directories ending in .cache 229 | !?*.[Cc]ache/ 230 | 231 | # Others 232 | ClientBin/ 233 | ~$* 234 | *~ 235 | *.dbmdl 236 | *.dbproj.schemaview 237 | *.jfm 238 | *.pfx 239 | *.publishsettings 240 | orleans.codegen.cs 241 | 242 | # Including strong name files can present a security risk 243 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 244 | #*.snk 245 | 246 | # Since there are multiple workflows, uncomment next line to ignore bower_components 247 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 248 | #bower_components/ 249 | 250 | # RIA/Silverlight projects 251 | Generated_Code/ 252 | 253 | # Backup & report files from converting an old project file 254 | # to a newer Visual Studio version. Backup files are not needed, 255 | # because we have git ;-) 256 | _UpgradeReport_Files/ 257 | Backup*/ 258 | UpgradeLog*.XML 259 | UpgradeLog*.htm 260 | ServiceFabricBackup/ 261 | *.rptproj.bak 262 | 263 | # SQL Server files 264 | *.mdf 265 | *.ldf 266 | *.ndf 267 | 268 | # Business Intelligence projects 269 | *.rdl.data 270 | *.bim.layout 271 | *.bim_*.settings 272 | *.rptproj.rsuser 273 | *- [Bb]ackup.rdl 274 | *- [Bb]ackup ([0-9]).rdl 275 | *- [Bb]ackup ([0-9][0-9]).rdl 276 | 277 | # Microsoft Fakes 278 | FakesAssemblies/ 279 | 280 | # GhostDoc plugin setting file 281 | *.GhostDoc.xml 282 | 283 | # Node.js Tools for Visual Studio 284 | .ntvs_analysis.dat 285 | node_modules/ 286 | 287 | # Visual Studio 6 build log 288 | *.plg 289 | 290 | # Visual Studio 6 workspace options file 291 | *.opt 292 | 293 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 294 | *.vbw 295 | 296 | # Visual Studio LightSwitch build output 297 | **/*.HTMLClient/GeneratedArtifacts 298 | **/*.DesktopClient/GeneratedArtifacts 299 | **/*.DesktopClient/ModelManifest.xml 300 | **/*.Server/GeneratedArtifacts 301 | **/*.Server/ModelManifest.xml 302 | _Pvt_Extensions 303 | 304 | # Paket dependency manager 305 | .paket/paket.exe 306 | paket-files/ 307 | 308 | # FAKE - F# Make 309 | .fake/ 310 | 311 | # CodeRush personal settings 312 | .cr/personal 313 | 314 | # Python Tools for Visual Studio (PTVS) 315 | __pycache__/ 316 | *.pyc 317 | 318 | # Cake - Uncomment if you are using it 319 | # tools/** 320 | # !tools/packages.config 321 | 322 | # Tabs Studio 323 | *.tss 324 | 325 | # Telerik's JustMock configuration file 326 | *.jmconfig 327 | 328 | # BizTalk build output 329 | *.btp.cs 330 | *.btm.cs 331 | *.odx.cs 332 | *.xsd.cs 333 | 334 | # OpenCover UI analysis results 335 | OpenCover/ 336 | 337 | # Azure Stream Analytics local run output 338 | ASALocalRun/ 339 | 340 | # MSBuild Binary and Structured Log 341 | *.binlog 342 | 343 | # NVidia Nsight GPU debugger configuration file 344 | *.nvuser 345 | 346 | # MFractors (Xamarin productivity tool) working folder 347 | .mfractor/ 348 | 349 | # Local History for Visual Studio 350 | .localhistory/ 351 | 352 | # BeatPulse healthcheck temp database 353 | healthchecksdb 354 | 355 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 356 | MigrationBackup/ 357 | 358 | # Ionide (cross platform F# VS Code tools) working folder 359 | .ionide/ 360 | -------------------------------------------------------------------------------- /src/Altinn.ApiClients.Maskinporten/Extensions/ServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using Altinn.ApiClients.Maskinporten.Config; 4 | using Altinn.ApiClients.Maskinporten.Factories; 5 | using Altinn.ApiClients.Maskinporten.Helpers; 6 | using Altinn.ApiClients.Maskinporten.Interfaces; 7 | using Altinn.ApiClients.Maskinporten.Services; 8 | using Microsoft.Extensions.Configuration; 9 | using Microsoft.Extensions.DependencyInjection; 10 | using Microsoft.Extensions.DependencyInjection.Extensions; 11 | 12 | namespace Altinn.ApiClients.Maskinporten.Extensions 13 | { 14 | /// 15 | /// We add all IClientDefinition implementation to the DI container and immediately add a reference to the corresponding 16 | /// httpClient and configuration to a static list via the helpers in MaskinportenClientDefinitionHelper. 17 | /// The MaskinportenHttpMessageHandlerFactory relies on the index of a given httpClient refence/configuration 18 | /// matching the index of the IClientDefinition service when injected as a IEnumerable<IClientDefinition> 19 | /// This way we can support having multiple named/typed HTTP clients using the same IClientDefinition implementation, 20 | /// (but different singleton instances), whilst the IClientDefinition can use normal DI in its constructors 21 | /// 22 | public static class ServiceCollectionExtensions 23 | { 24 | /// 25 | /// Registers a Maskinporten client definition instance with a unique key. This allows for several instances using the 26 | /// same client definition but varying configurations. Use IHttpClientBuilder.AddMaskinportenHttpMessageHandler 27 | /// to attach it to a HttpClient, using the same key. 28 | /// 29 | /// The client definition 30 | /// Service collection 31 | /// Key to uniquely identify this client definition instance 32 | /// Instance of IMaskinportenSettings to use 33 | public static void RegisterMaskinportenClientDefinition(this IServiceCollection services, 34 | string clientDefinitionKey, IMaskinportenSettings settings) 35 | where TClientDefinition : class, IClientDefinition 36 | { 37 | AddMaskinportenClientCommon(services); 38 | services.AddSingleton(); 39 | MaskinportenClientDefinitionHelper.AddClientDefinitionInstance(clientDefinitionKey, settings); 40 | } 41 | 42 | /// 43 | /// Registers a Maskinporten client definition instance with a unique key. This allows for several instances using the 44 | /// same client definition but varying configurations. Use IHttpClientBuilder.AddMaskinportenHttpMessageHandler 45 | /// to attach it to a HttpClient, using the same key. 46 | /// 47 | /// The client definition 48 | /// Service collection 49 | /// Key to uniquely identify this client definition instance 50 | /// IConfiguration instance containing fields that will be bound to MaskinportenSettings 51 | public static void RegisterMaskinportenClientDefinition(this IServiceCollection services, 52 | string clientDefinitionKey, IConfiguration config) 53 | where TClientDefinition : class, IClientDefinition => 54 | RegisterMaskinportenClientDefinition(services, clientDefinitionKey, BindConfigToInstanceOf(config)); 55 | 56 | /// 57 | /// Adds a named Maskinporten-enabled HTTP client with the given configuration which can be created with HttpClientFactory 58 | /// 59 | /// The client definition 60 | /// Service collection 61 | /// Name of HTTP client. 62 | /// Instance of IMaskinportenSettings to use 63 | /// Delegate for configuring the client definition 64 | public static IHttpClientBuilder AddMaskinportenHttpClient(this IServiceCollection services, 65 | string httpClientName, IMaskinportenSettings settings, Action configureClientDefinition = null) 66 | where TClientDefinition : class, IClientDefinition 67 | { 68 | services.RegisterMaskinportenClientDefinition(httpClientName, settings); 69 | return services.AddHttpClient(httpClientName) 70 | .AddMaskinportenHttpMessageHandler(httpClientName, configureClientDefinition); 71 | } 72 | 73 | /// 74 | /// Adds a named Maskinporten-enabled HTTP client with the given configuration which can be created with HttpClientFactory 75 | /// 76 | /// The client definition 77 | /// Service collection 78 | /// Name of HTTP client. 79 | /// IConfiguration instance containing fields that will be bound to MaskinportenSettings 80 | /// Delegate for configuring the client definition 81 | public static IHttpClientBuilder AddMaskinportenHttpClient(this IServiceCollection services, 82 | string httpClientName, IConfiguration config, Action configureClientDefinition = null) 83 | where TClientDefinition : class, IClientDefinition => 84 | AddMaskinportenHttpClient(services, httpClientName, BindConfigToInstanceOf(config), configureClientDefinition); 85 | 86 | /// 87 | /// Adds a typed Maskinporten-enabled HTTP client with the given configuration to the dependency injection container 88 | /// 89 | /// The client definition 90 | /// The HTTP client type 91 | /// Service collection 92 | /// Instance of IMaskinportenSettings to use 93 | /// Delegate for configuring the client definition 94 | public static IHttpClientBuilder AddMaskinportenHttpClient(this IServiceCollection services, 95 | IMaskinportenSettings settings, Action configureClientDefinition = null) 96 | where TClientDefinition : class, IClientDefinition 97 | where THttpClient : class 98 | { 99 | services.RegisterMaskinportenClientDefinition( 100 | MaskinportenClientDefinitionHelper.GetClientDefinitionKey(), settings); 101 | 102 | return services.AddHttpClient() 103 | .AddMaskinportenHttpMessageHandler(configureClientDefinition); 104 | } 105 | 106 | /// 107 | /// Adds a typed Maskinporten-enabled HTTP client with the given configuration to the dependency injection container 108 | /// 109 | /// The client definition 110 | /// The HTTP client type 111 | /// The HTTP client implementation type 112 | /// Service collection 113 | /// Instance of IMaskinportenSettings to use 114 | /// Delegate for configuring the client definition 115 | public static IHttpClientBuilder AddMaskinportenHttpClient(this IServiceCollection services, 116 | IMaskinportenSettings settings, Action configureClientDefinition = null) 117 | where TClientDefinition : class, IClientDefinition 118 | where THttpClient : class 119 | where THttpClientImplementation : class, THttpClient 120 | { 121 | services.RegisterMaskinportenClientDefinition( 122 | MaskinportenClientDefinitionHelper.GetClientDefinitionKey(), settings); 123 | 124 | return services.AddHttpClient() 125 | .AddMaskinportenHttpMessageHandler(configureClientDefinition); 126 | } 127 | 128 | /// 129 | /// Adds a typed Maskinporten-enabled HTTP client with the given configuration to the dependency injection container 130 | /// 131 | /// The client definition 132 | /// The HTTP client type 133 | /// The HTTP client implementation type 134 | /// Service collection 135 | /// IConfiguration instance containing fields that will be bound to MaskinportenSettings 136 | /// Delegate for configuring the client definition 137 | public static IHttpClientBuilder AddMaskinportenHttpClient(this IServiceCollection services, 138 | IConfiguration config, Action configureClientDefinition = null) 139 | where TClientDefinition : class, IClientDefinition 140 | where THttpClient : class 141 | where THttpClientImplementation : class, THttpClient => 142 | AddMaskinportenHttpClient(services, 143 | BindConfigToInstanceOf(config), configureClientDefinition); 144 | 145 | /// 146 | /// Adds a typed Maskinporten-enabled HTTP client with the given configuration to the dependency injection container 147 | /// 148 | /// The client definition 149 | /// The HTTP client type 150 | /// Service collection 151 | /// IConfiguration instance containing fields that will be bound to MaskinportenSettings 152 | /// Delegate for configuring the client definition 153 | public static IHttpClientBuilder AddMaskinportenHttpClient(this IServiceCollection services, 154 | IConfiguration config, Action configureClientDefinition = null) 155 | where TClientDefinition : class, IClientDefinition 156 | where THttpClient : class => 157 | AddMaskinportenHttpClient(services, 158 | BindConfigToInstanceOf(config), configureClientDefinition); 159 | 160 | private static void AddMaskinportenClientCommon(IServiceCollection services) 161 | { 162 | // We need a provider to cache tokens. If one is not already provided by the user, use MemoryTokenCacheProvider 163 | if (services.All(x => x.ServiceType != typeof(ITokenCacheProvider))) 164 | { 165 | services.AddMemoryCache(); 166 | services.TryAddSingleton(); 167 | } 168 | 169 | // We only need a single Maskinporten-service for all clients. This can be used directly if low level access is required. 170 | services.TryAddSingleton(); 171 | services.TryAddSingleton(); 172 | } 173 | 174 | private static TBoundInstance BindConfigToInstanceOf(IConfiguration configuration) 175 | where TBoundInstance : class, new() 176 | { 177 | var boundInstance = new TBoundInstance(); 178 | configuration.Bind(boundInstance); 179 | return boundInstance; 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # .NET client for Maskinporten APIs 2 | 3 | This .NET client library is used for calling maskinporten and create an access token to be used for services that require an Maskinporten access token. This also supports exchanging Maskinporten tokens into Altinn tokens, as well as enriching tokens with enterprise user credentials. 4 | 5 | ## Installation 6 | 7 | Install the nuget with `dotnet add package Altinn.ApiClients.Maskinporten` or similar. 8 | 9 | Pre-release versions of this nuget are made available on Github. 10 | 11 | ## Usage 12 | 13 | This library provides extensions methods providing means to configure one or more HttpClients that can be injected and used transparently as any other HttpClient instance. 14 | 15 | You will need to configure a client definition, which is a way of providing the necessary OAuth2-related settings (client-id, scopes etc), as well as a way of getting the secret (either a X.509 certificate with a private key or a JWK with a private key) used to sign the requests to Maskinporten. The client definition also contains other settings, such as whether Altinn token exchange should be used. 16 | 17 | > Note: There are several different client definition types built-in that can be used for aquiring secrets from various, or one can provide a custom one if required. It is also possible to create several named/typed clients using different combinations of settings and definition types. See below for a list of builtin client definitions, and the "SampleWebApp"-project (especially Startup.cs) for examples on how this can be done and extended with your own custom definitions if required. 18 | 19 | Here is an example with a both a [named](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/http-requests?view=aspnetcore-6.0#named-clients) and [typed](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/http-requests?view=aspnetcore-6.0#typed-clients) client using a client definition where the secret is a private RSA key in a JWK supplied in the injected settings. 20 | 21 | 1. Client needs to configured in `ConfigureServices`, where `services` is a `IServiceCollection`. Configuration settings for the client is provided as a instance of `MaskinportenSettings` (or any instance implementing `IMaskinportenSettings`). Alternatively, one can pass a `IConfiguration` instance to an overload that will bind this to a `MaskinportenSettings` instance. 22 | 23 | ```c# 24 | 25 | // We assume `Configuration` is a IConfiguration instance containing the settings from eg. appsettings.json. 26 | 27 | var maskinportenSettings = new MaskinportenSettings(); 28 | Configuration.GetSection("MaskinportenSettings").Bind(maskinportenSettings); 29 | 30 | // Named client 31 | services.AddMaskinportenHttpClient("myhttpclient", maskinportenSettings); 32 | 33 | // For convenience you can pass the IConfiguration instance directly 34 | // services.AddMaskinportenHttpClient("myhttpclient", Configuration.GetSection("MaskinportenSettings")); 35 | 36 | // Typed client (MyMaskinportenHttpClient is any class accepting a HttpClient paramter in its constructor) 37 | services.AddMaskinportenHttpClient(maskinportenSettings); 38 | 39 | // Another typed client, using the same app settings, but overriding the setting for Altinn token exchange 40 | services.AddMaskinportenHttpClient( 41 | maskinportenSettings, clientDefinition => 42 | { 43 | clientDefinition.ClientSettings.ExhangeToAltinnToken = true; 44 | }); 45 | 46 | // You can chain additional handlers or configure the client if required 47 | services.AddMaskinportenHttpClient(maskinportenSettings) 48 | .AddHttpMessageHandler(sp => ...) 49 | .ConfigureHttpClient(client => ...) 50 | 51 | // Registering av Maskinporten-powered client without adding it to HttpClientFactory / DIC 52 | services.RegisterMaskinportenClientDefinition( 53 | "my-client-definition-instance-key", 54 | maskinportenSettings); 55 | 56 | // This can then be added as a HttpMessageHandler to any IClientBuilder. This is 57 | // useful if you're already using a client builder (DAN, Polly, Refit etc). 58 | services.AddHttpClient() 59 | .AddMaskinportenHttpMessageHandler("my-client-definition-instance-key"); 60 | 61 | 62 | ``` 63 | 2. Configure Maskinporten environment in appsetting.json 64 | 65 | ```jsonc 66 | 67 | // Settings from appsettings.json, environment variables or other configuration providers. 68 | // The first three are always mandatory for all client definitions types 69 | "MaskinportenSettings": { 70 | // 1. Valid values are ver1, ver2 and prod 71 | "Environment": "ver2", 72 | 73 | // 2. Client Id/integration as configured in Maskinporten 74 | "ClientId": "e15abbbc-36ad-4300-abe9-021c9a245e20", 75 | 76 | // 3. Scope(s) requested, space seperated. Must be provisioned on supplied client id. 77 | "Scope": "altinn:serviceowner/readaltinn", 78 | 79 | // -------------------------- 80 | // Any additional settings are specific for the selected client definition type. 81 | // See below for examples using other types. 82 | "EncodedJwk": "eyJwIjoiMms2RlZMRW9iVVY0dmpjRjRCVWNLOUhasdfasdfarhgawfN2YXE5eE95a3NyS1Q345435S19oNV45645635423545t45t54wrgsdfgsfdgsfd444aefasdf5NzdFcWhGTGtaSVAzSmhZTlA0MEZOc1EifQ==" 83 | } 84 | ``` 85 | 86 | 3. Using the client 87 | 88 | ```c# 89 | // The Maskinporten-enabled client can then be utilized like any other HttpClient via HttpClientFactory, eg DI-ed in a controller like this: 90 | public class MyController : ControllerBase 91 | { 92 | private readonly IHttpClientFactory _clientFactory; 93 | private readonly MyMaskinportenHttpClient _myMaskinportenHttpClient; 94 | 95 | public MyController( 96 | IHttpClientFactory clientFactory, MyMaskinportenHttpClient myMaskinportenHttpClient) 97 | { 98 | _clientFactory = clientFactory; 99 | _myMaskinportenHttpClient = myMaskinportenHttpClient; 100 | } 101 | 102 | [HttpGet] 103 | public async Task Get() 104 | { 105 | // Here we use the named client we configured earlier 106 | var myclient = _clientFactory.CreateClient("myhttpclient"); 107 | 108 | // This request will be sent with a Authorization-header containing a bearer token 109 | var result = await client.GetAsync("https://example.com/"); 110 | 111 | // Or we can use the typed client we made instead. Any 112 | // requests made to the HttpClient instance injected will have a bearer token. 113 | _myMaskinportenHttpClient.DoStuff(); 114 | } 115 | } 116 | ``` 117 | ## Built-in client definitions 118 | 119 | | Name | Description 120 | | -----------------| ------------- 121 | | SettingsJwk | Uses a Base64-encoded RSA keypair in a JWK supplied in injected settings 122 | | SettingsX509 | Uses a Base64-encoded X.509 certificate with a private key supplied in injected settings 123 | | Pkcs12 | Uses a password-protected PKCS#12 formatted certificate file on disk 124 | | CertificateStore | Uses a thumbprint in Windows Certificate Store (LocalMachine\My) 125 | 126 |
See examples using the various client definition types 127 | 128 | Below are usage examples. 129 | 130 | ### SettingsJwk 131 | 132 | ```c# 133 | services.AddMaskinportenHttpClient( ... ) 134 | ``` 135 | 136 | ```jsonc 137 | "MaskinportenSettings": { 138 | // ... 139 | "EncodedJwk": "eyJwIjoiMms2RlZMRW9iV..." 140 | } 141 | ``` 142 | 143 | ### SettingsX509 144 | 145 | ```c# 146 | services.AddMaskinportenHttpClient( ... ) 147 | ``` 148 | 149 | ```jsonc 150 | "MaskinportenSettings": { 151 | // ... 152 | "EncodedX509": "MIIwIjoiMms2RlZMRW9i..." 153 | } 154 | ``` 155 | 156 | ### Pkcs12 157 | 158 | ```c# 159 | services.AddMaskinportenHttpClient( ... ) 160 | ``` 161 | 162 | ```jsonc 163 | "MaskinportenSettings": { 164 | // ... 165 | "CertificatePkcs12Path": "Certs/mycert.p12", 166 | "CertificatePkcs12Password": "mysecretpassword", 167 | } 168 | ``` 169 | 170 | ### CertificateStore 171 | 172 | ```c# 173 | services.AddMaskinportenHttpClient( ... ) 174 | ``` 175 | 176 | ```jsonc 177 | "MaskinportenSettings": { 178 | // ... 179 | "CertificateStoreThumbprint": "4325B22433984608AB5049103837F11C6BCA520D", 180 | } 181 | ``` 182 |
183 | 184 | ## Custom client definitions 185 | If you need to fetch the secret from some other source, you can provide your own implementation of `IClientDefinition` and pass it a custom `IMaskinportenSettings` instance containing any additional settings your source requires 186 | 187 | ```c# 188 | // ---- MyExtendedMaskinportenSettings.cs ---- 189 | public class MyExtendedMaskinportenSettings : MaskinportenSettings 190 | { // Extends MaskinportenSettings to avoid having to specify every field in IMaskinportenSettings 191 | 192 | public string MyCustomSetting { get; set; } 193 | } 194 | 195 | // ---- MyCustomClientDefinition.cs ---- 196 | public class MyCustomClientDefinition : IClientDefinition 197 | { 198 | public IMaskinportenSettings ClientSettings { get; set; } 199 | 200 | // The custom client definitions are registered in the DIC as singletons 201 | public MyCustomClientDefinition(ILogger logger) 202 | { 203 | _logger = logger; 204 | } 205 | 206 | public async Task GetClientSecrets() 207 | { 208 | var myExtendedSettings = (MyExtendedMaskinportenSettings)ClientSettings; 209 | // use myExtendedSettings.MyCustomSetting 210 | ... 211 | } 212 | } 213 | 214 | // ---- Program.cs / Startup.cs ---- 215 | var myExtendedMaskinportenSettings = new MyExtendedMaskinportenSettings(); 216 | // We assume `Configuration` is a IConfiguration instance containing the settings from eg. appsettings.json 217 | // and that `services` is a IServiceCollection 218 | Configuration.GetSection("ExtendedMaskinportenSettings").Bind(myExtendedMaskinportenSettings); 219 | 220 | // Named client using a custom client definition and settings 221 | services.AddMaskinportenHttpClient("myhttpclient", myExtendedMaskinportenSettings); 222 | ``` 223 | 224 | ## Using with Azure Keyvault as configuration provider i Azure App Services 225 | 226 | JWKs or certificates can be injected into application settings for Azure App Services or Azure Functions using [key vault references](https://docs.microsoft.com/en-us/azure/app-service/app-service-key-vault-references?tabs=azure-cli). This can then be easily used with the `SettingsJwk` or `SettingsX509` client definitions. 227 | 228 | Given that your applications managed identity has access to the key vault containing the secret/cert, you can specify the appsetting value like this: 229 | 230 | ```jsonc 231 | "MaskinportenSettings": { 232 | // ... 233 | "EncodedJwk": "@Microsoft.KeyVault(VaultName=myvault;SecretName=mysecretjwk)" 234 | } 235 | ``` 236 | or for certificates: 237 | 238 | ```jsonc 239 | "MaskinportenSettings": { 240 | // ... 241 | "EncodedX509": "@Microsoft.KeyVault(VaultName=myvault;SecretName=mycertificate)" 242 | } 243 | ``` 244 | 245 | 246 | 247 | ## Using Altinn token exchange 248 | 249 | If you require an [Altinn Exchanged token](https://docs.altinn.studio/altinn-api/authentication/#maskinporten-jwt-access-token-input), this can be performed transparently by 250 | supplying the following field to the settings object. 251 | 252 | ```json 253 | "ExhangeToAltinnToken": true 254 | ```` 255 | This will transparently exchange (and cache) the Maskinporten-token into an Altinn-token which can be used against Altinn APIs. 256 | 257 | If you require an Altinn Exchanged token for the TTD organisation, this is supported by including the field below in the settings. 258 | 259 | ```json 260 | "UseAltinnTestOrg": true 261 | ``` 262 | 263 | The environment for the token exchange is by default derived from the Maskinporten environment. This can also be explicitly configured: 264 | 265 | ```jsonc 266 | // Valid values: at21, at22, at23, at24, tt02, prod 267 | "TokenExchangeEnvironment": "at24" 268 | ``` 269 | 270 | These settings can also be supplied by providing a delegate like: 271 | 272 | ```c# 273 | services.AddMaskinportenHttpClient( 274 | Configuration.GetSection("MaskinportenSettings"), clientDefinition => 275 | { 276 | clientDefinition.ClientSettings.ExhangeToAltinnToken = true; 277 | clientDefinition.ClientSettings.UseAltinnTestOrg = true; 278 | clientDefinition.ClientSettings.TokenExchangeEnvironment = "at24"; 279 | }); 280 | ``` 281 | 282 | 283 | ## Authenticating with a enterprise user 284 | 285 | This library also supports enriching Maskinporten tokens with enterprise user credentials for APIs requiring user roles/rights. In order to do this, you will need to add the following fields to the configuration, containing the enterpriseuser's username and password. 286 | 287 | ```json 288 | "EnterpriseUserName": "myenterpriseuser", 289 | "EnterpriseUserPassword": "mysecret", 290 | ```` 291 | 292 | ## Custom cache provider (2.x and later) 293 | 294 | By default, this library will cache tokens using MemoryCache via `MemoryCacheTokenProvider`, allowing tokens to be reused as long as they are valid (based on `exp`-claim). If your application has other caching needs, you can provide your own implementation of `ITokenCacheProvider` by registering your implementation as a service before calling `AddMaskinportenHttpClient`. 295 | 296 | ```c# 297 | services.AddSingleton(); 298 | ``` 299 | 300 | `FileTokenCacheProvider` is included in the library, which uses a file based cache. 301 | 302 | **See the "SampleWebApp"-project (especially Startup.cs) for more examples on various client defintions, cache providers, custom definitions and several clients with different configurations** 303 | 304 | 305 | ## Manual use of TokenService 306 | 307 | 1. Client needs to configured in startup 308 | 309 | ```c# 310 | // Maskinporten requires a cache implementation. Note: for 1.x of this library, use MemoryCache directly 311 | services.AddSingleton(); 312 | 313 | // We also need at least one HTTP client in order to fetch tokens 314 | services.AddHttpClient(); 315 | 316 | // Adds service can be used directly, see below. This exposes several GetToken() overloads. 317 | services.AddSingleton(); 318 | ``` 319 | 320 | 3. Configure client in constructur for service that need Maskinporten token 321 | 322 | 4. Call maskinporten API to get the token. 323 | 324 | ```c# 325 | private async Task GetMaskinportenAccessToken() 326 | { 327 | try 328 | { 329 | string accessToken = null; 330 | string base64encodedJWK = await _secrets.GetSecretAsync("myBase64EncodedJwkWithPrivateKey"); 331 | TokenResponse accesstokenResponse = await _maskinporten.GetToken( 332 | base64encodedJWK, _maskinportenSettings.ClientId, _maskinportenSettings.Scope, null); 333 | 334 | return accesstokenResponse.AccessToken; 335 | } 336 | } 337 | ``` 338 | 339 | ## Troubleshooting 340 | 341 | When facing issues, you might want to temporarily enable debug logging in the settings by adding the following key: 342 | 343 | ```json 344 | "EnableDebugLogging": true 345 | ``` 346 | 347 | This will cause various information to be logged with severity "Information" to the injected logger. All log entries will have the prefix `[Altinn.ApiClients.Maskinporten DEBUG]: `. 348 | > Warning! This will cause signed assertions (a short-lived secret) to be logged, so only use this in troubleshooting scenarios. No private key material will be logged. 349 | -------------------------------------------------------------------------------- /src/Altinn.ApiClients.Maskinporten/Services/MaskinportenService.cs: -------------------------------------------------------------------------------- 1 | using Altinn.ApiClients.Maskinporten.Models; 2 | using Microsoft.Extensions.Logging; 3 | using Microsoft.IdentityModel.Tokens; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.IdentityModel.Tokens.Jwt; 7 | using System.Net.Http; 8 | using System.Net.Http.Headers; 9 | using System.Security.Cryptography; 10 | using System.Security.Cryptography.X509Certificates; 11 | using System.Text; 12 | using System.Text.Json; 13 | using System.Threading; 14 | using System.Threading.Tasks; 15 | using Altinn.ApiClients.Maskinporten.Interfaces; 16 | 17 | namespace Altinn.ApiClients.Maskinporten.Services 18 | { 19 | public class MaskinportenService : IMaskinportenService 20 | { 21 | private readonly HttpClient _client; 22 | 23 | private readonly ILogger _logger; 24 | 25 | private readonly ITokenCacheProvider _tokenCacheProvider; 26 | 27 | private static readonly SemaphoreSlim SemaphoreSlim = new SemaphoreSlim(1, 1); 28 | 29 | private bool _enableDebugLogging; 30 | 31 | public MaskinportenService(HttpClient httpClient, 32 | ILogger logger, 33 | ITokenCacheProvider tokenCacheProvider) 34 | { 35 | httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); 36 | _client = httpClient; 37 | _logger = logger; 38 | _tokenCacheProvider = tokenCacheProvider; 39 | } 40 | 41 | public async Task GetToken(X509Certificate2 cert, string environment, string clientId, string scope, string resource, string consumerOrgNo = null, bool disableCaching = false) 42 | { 43 | return await GetToken(cert, null, environment, clientId, scope, resource, consumerOrgNo, disableCaching); 44 | } 45 | 46 | public async Task GetToken(JsonWebKey jwk, string environment, string clientId, string scope, string resource, string consumerOrgNo = null, bool disableCaching = false) 47 | { 48 | return await GetToken(null, jwk, environment, clientId, scope, resource, consumerOrgNo, disableCaching); 49 | } 50 | 51 | public async Task GetToken(string base64EncodedJwk, string environment, string clientId, string scope, string resource, string consumerOrgNo = null, bool disableCaching = false) 52 | { 53 | byte[] base64EncodedBytes = Convert.FromBase64String(base64EncodedJwk); 54 | string jwkjson = Encoding.UTF8.GetString(base64EncodedBytes); 55 | JsonWebKey jwk = new JsonWebKey(jwkjson); 56 | return await GetToken(null, jwk, environment, clientId, scope, resource, consumerOrgNo, disableCaching); 57 | } 58 | 59 | public async Task GetToken(IClientDefinition clientDefinition, bool disableCaching = false) 60 | { 61 | if (clientDefinition.ClientSettings.EnableDebugLogging.HasValue && 62 | clientDefinition.ClientSettings.EnableDebugLogging.Value) 63 | { 64 | _enableDebugLogging = true; 65 | } 66 | 67 | ClientSecrets clientSecrets = await clientDefinition.GetClientSecrets(); 68 | 69 | DebugLog($"GetToken: ClientID: {clientDefinition.ClientSettings.ClientId}"); 70 | 71 | TokenResponse tokenResponse; 72 | if (clientSecrets.ClientKey != null) 73 | { 74 | DebugLog($"GetToken: Using JWK, N={clientSecrets.ClientKey.N}"); 75 | tokenResponse = await GetToken(null, clientSecrets.ClientKey, 76 | clientDefinition.ClientSettings.Environment, clientDefinition.ClientSettings.ClientId, 77 | clientDefinition.ClientSettings.Scope, clientDefinition.ClientSettings.Resource, 78 | clientDefinition.ClientSettings.ConsumerOrgNo, disableCaching); 79 | } 80 | else if (clientSecrets.ClientCertificate != null) 81 | { 82 | DebugLog($"GetToken: Using certificate, subject={clientSecrets.ClientCertificate.Subject}"); 83 | tokenResponse = await GetToken(clientSecrets.ClientCertificate, null, 84 | clientDefinition.ClientSettings.Environment, clientDefinition.ClientSettings.ClientId, 85 | clientDefinition.ClientSettings.Scope, clientDefinition.ClientSettings.Resource, 86 | clientDefinition.ClientSettings.ConsumerOrgNo, disableCaching); 87 | } 88 | else 89 | { 90 | throw new Exception("MaskinportenService: Missing settings!"); 91 | } 92 | 93 | // Check if we have an explicitly set environment for token exchange, else derive it from Maskinporten environment 94 | string tokenExchangeEnvironment = 95 | clientDefinition.ClientSettings.TokenExchangeEnvironment ?? clientDefinition.ClientSettings.Environment; 96 | 97 | if (!string.IsNullOrEmpty(clientDefinition.ClientSettings.EnterpriseUserName) && 98 | !string.IsNullOrEmpty(clientDefinition.ClientSettings.EnterpriseUserPassword)) 99 | { 100 | DebugLog($"GetToken: Using enterprise username and password"); 101 | return await ExchangeToAltinnToken(tokenResponse, tokenExchangeEnvironment, clientDefinition.ClientSettings.EnterpriseUserName, 102 | clientDefinition.ClientSettings.EnterpriseUserPassword, disableCaching); 103 | } 104 | 105 | if (clientDefinition.ClientSettings.ExhangeToAltinnToken.HasValue && 106 | clientDefinition.ClientSettings.ExhangeToAltinnToken.Value) 107 | { 108 | if (clientDefinition.ClientSettings.UseAltinnTestOrg.HasValue) 109 | { 110 | return await ExchangeToAltinnToken( 111 | tokenResponse, 112 | tokenExchangeEnvironment, 113 | disableCaching: disableCaching, 114 | isTestOrg: clientDefinition.ClientSettings.UseAltinnTestOrg.Value); 115 | } 116 | 117 | return await ExchangeToAltinnToken(tokenResponse, tokenExchangeEnvironment, disableCaching: disableCaching); 118 | } 119 | 120 | return tokenResponse; 121 | } 122 | 123 | public async Task ExchangeToAltinnToken( 124 | TokenResponse tokenResponse, 125 | string environment, 126 | string userName = null, 127 | string password = null, 128 | bool disableCaching = false, 129 | bool isTestOrg = false) 130 | { 131 | string cacheKey = GetCacheKeyForTokenAndUsername(tokenResponse, userName ?? string.Empty); 132 | await SemaphoreSlim.WaitAsync(); 133 | try 134 | { 135 | if (!disableCaching) 136 | { 137 | (bool hasCachedValue, TokenResponse cachedTokenResponse) = await _tokenCacheProvider.TryGetToken(cacheKey); 138 | if (hasCachedValue) 139 | { 140 | DebugLog("ExchangeToAltinnToken: returning cached value"); 141 | return cachedTokenResponse; 142 | } 143 | } 144 | 145 | DebugLog("ExchangeToAltinnToken: cache miss or cache disabled"); 146 | 147 | HttpRequestMessage requestMessage = new HttpRequestMessage() 148 | { 149 | Method = HttpMethod.Get, 150 | RequestUri = new Uri(GetTokenExchangeEndpoint(environment)), 151 | Headers = { Authorization = new AuthenticationHeaderValue("Bearer", tokenResponse.AccessToken) } 152 | }; 153 | 154 | if (isTestOrg) 155 | { 156 | DebugLog("ExchangeToAltinnToken: isTestOrg is true"); 157 | requestMessage.RequestUri = new Uri(requestMessage.RequestUri + "?test=true"); 158 | } 159 | 160 | TokenResponse exchangedTokenResponse = new TokenResponse 161 | { 162 | ExpiresIn = tokenResponse.ExpiresIn, 163 | Scope = tokenResponse.Scope, 164 | TokenType = "altinn" 165 | }; 166 | 167 | if (userName != null && password != null) 168 | { 169 | requestMessage.Headers.TryAddWithoutValidation("X-Altinn-EnterpriseUser-Authentication", 170 | Convert.ToBase64String(Encoding.UTF8.GetBytes($"{userName}:{password}"))); 171 | 172 | DebugLog("ExchangeToAltinnToken: Setting X-Altinn-EnterpriseUser-Authentication"); 173 | } 174 | else 175 | { 176 | DebugLog("ExchangeToAltinnToken: not setting X-Altinn-EnterpriseUser-Authentication, missing settings?"); 177 | } 178 | 179 | DebugLog($"ExchangeToAltinnToken: Attempting token exchange at {requestMessage.RequestUri}"); 180 | 181 | exchangedTokenResponse.AccessToken = await PerformRequest(requestMessage); 182 | 183 | DebugLog($"ExchangeToAltinnToken: Received token, expires in {exchangedTokenResponse.ExpiresIn} seconds"); 184 | 185 | await _tokenCacheProvider.Set(cacheKey, exchangedTokenResponse, 186 | new TimeSpan(0, 0, Math.Max(0, exchangedTokenResponse.ExpiresIn - 5))); 187 | 188 | return exchangedTokenResponse; 189 | } 190 | finally 191 | { 192 | SemaphoreSlim.Release(); 193 | } 194 | } 195 | 196 | private string GetCacheKeyForTokenAndUsername(TokenResponse tokenResponse, string userName) 197 | { 198 | MD5 md5 = MD5.Create(); 199 | return BitConverter.ToString(md5.ComputeHash(Encoding.UTF8.GetBytes(tokenResponse.AccessToken + userName))); 200 | } 201 | 202 | private string GetJwtAssertion(X509Certificate2 cert, JsonWebKey jwk, string environment, string clientId, string scope, string resource, string consumerOrg) 203 | { 204 | DateTimeOffset dateTimeOffset = new DateTimeOffset(DateTime.UtcNow); 205 | JwtHeader header = cert != null ? GetHeader(cert) : GetHeader(jwk); 206 | 207 | JwtPayload payload = new JwtPayload 208 | { 209 | { "aud", GetAssertionAud(environment) }, 210 | { "scope", scope }, 211 | { "iss", clientId }, 212 | { "exp", dateTimeOffset.ToUnixTimeSeconds() + 10 }, 213 | { "iat", dateTimeOffset.ToUnixTimeSeconds() }, 214 | { "jti", Guid.NewGuid().ToString() }, 215 | }; 216 | 217 | if (!string.IsNullOrEmpty(resource)) 218 | { 219 | payload.Add("resource", resource); 220 | } 221 | 222 | if (!string.IsNullOrEmpty(consumerOrg)) 223 | { 224 | payload.Add("consumer_org", consumerOrg); 225 | } 226 | 227 | JwtSecurityToken securityToken = new JwtSecurityToken(header, payload); 228 | JwtSecurityTokenHandler handler = new JwtSecurityTokenHandler(); 229 | 230 | string assertion = handler.WriteToken(securityToken); 231 | DebugLog($"GetJwtAssertion: {assertion}"); 232 | return assertion; 233 | } 234 | 235 | private async Task GetToken(X509Certificate2 cert, JsonWebKey jwk, string environment, string clientId, string scope, string resource, string consumerOrg, bool disableCaching) 236 | { 237 | string cacheKey = $"{clientId}-{scope}-{resource}-{consumerOrg}"; 238 | 239 | await SemaphoreSlim.WaitAsync(); 240 | try 241 | { 242 | if (!disableCaching) 243 | { 244 | (bool hasCachedValue, TokenResponse cachedTokenResponse) = await _tokenCacheProvider.TryGetToken(cacheKey); 245 | if (hasCachedValue) 246 | { 247 | DebugLog("GetToken: returning cached value"); 248 | return cachedTokenResponse; 249 | } 250 | } 251 | 252 | DebugLog("GetToken: cache miss or cache disabled"); 253 | 254 | string jwtAssertion = GetJwtAssertion(cert, jwk, environment, clientId, scope, resource, consumerOrg); 255 | HttpRequestMessage requestMessage = new HttpRequestMessage() 256 | { 257 | Method = HttpMethod.Post, 258 | RequestUri = new Uri(GetTokenEndpoint(environment)), 259 | Content = GetUrlEncodedContent(jwtAssertion) 260 | }; 261 | 262 | DebugLog($"GetToken: Requesting token from {GetTokenEndpoint(environment)}"); 263 | 264 | TokenResponse accesstokenResponse = await PerformRequest(requestMessage); 265 | 266 | DebugLog($"GetToken: Received token, expires in {accesstokenResponse.ExpiresIn} seconds"); 267 | 268 | await _tokenCacheProvider.Set(cacheKey, accesstokenResponse, 269 | new TimeSpan(0, 0, Math.Max(0, accesstokenResponse.ExpiresIn - 5))); 270 | return accesstokenResponse; 271 | } 272 | finally 273 | { 274 | SemaphoreSlim.Release(); 275 | } 276 | } 277 | 278 | private JwtHeader GetHeader(JsonWebKey jwk) 279 | { 280 | return new JwtHeader(new SigningCredentials(jwk, SecurityAlgorithms.RsaSha256)); 281 | } 282 | 283 | private JwtHeader GetHeader(X509Certificate2 cert) 284 | { 285 | X509SecurityKey securityKey = new X509SecurityKey(cert); 286 | JwtHeader header = new JwtHeader(new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha256)) 287 | { 288 | { "x5c", new List() { Convert.ToBase64String(cert.GetRawCertData()) } } 289 | }; 290 | header.Remove("typ"); 291 | header.Remove("kid"); 292 | 293 | return header; 294 | } 295 | 296 | private FormUrlEncodedContent GetUrlEncodedContent(string assertion) 297 | { 298 | FormUrlEncodedContent formContent = new FormUrlEncodedContent(new List> 299 | { 300 | new("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), 301 | new("assertion", assertion), 302 | }); 303 | 304 | return formContent; 305 | } 306 | 307 | public async Task PerformRequest(HttpRequestMessage requestMessage) 308 | { 309 | HttpResponseMessage response = await _client.SendAsync(requestMessage); 310 | 311 | if (response.IsSuccessStatusCode) 312 | { 313 | string successResponse = await response.Content.ReadAsStringAsync(); 314 | return JsonSerializer.Deserialize(successResponse); 315 | } 316 | 317 | string errorResponse = await response.Content.ReadAsStringAsync(); 318 | ErrorReponse error; 319 | try 320 | { 321 | error = JsonSerializer.Deserialize(errorResponse); 322 | } 323 | catch (JsonException) 324 | { 325 | error = new ErrorReponse 326 | { 327 | ErrorType = "Other", 328 | Description = "An error occured, received from server: " + 329 | (string.IsNullOrEmpty(errorResponse) ? "" : errorResponse) 330 | }; 331 | } 332 | 333 | _logger.LogError("errorType={errorType} description={description} statuscode={statusCode}", error!.ErrorType, error.Description, response.StatusCode); 334 | throw new TokenRequestException(error.Description); 335 | } 336 | 337 | private void DebugLog(string message, params object[] args) 338 | { 339 | if (!_enableDebugLogging) return; 340 | _logger.LogInformation("[Altinn.ApiClients.Maskinporten DEBUG]: " + message, args); 341 | } 342 | 343 | private string GetAssertionAud(string environment) 344 | { 345 | return environment switch 346 | { 347 | "prod" => "https://maskinporten.no/", 348 | "ver1" => "https://ver1.maskinporten.no/", 349 | "ver2" => "https://ver2.maskinporten.no/", 350 | "test" => "https://test.maskinporten.no/", 351 | _ => throw new ArgumentException("Invalid environment setting. Valid values: prod, ver1, ver2, test") 352 | }; 353 | } 354 | 355 | private string GetTokenEndpoint(string environment) 356 | { 357 | return environment switch 358 | { 359 | "prod" => "https://maskinporten.no/token", 360 | "ver1" => "https://ver1.maskinporten.no/token", 361 | "ver2" => "https://ver2.maskinporten.no/token", 362 | "test" => "https://test.maskinporten.no/token", 363 | _ => throw new ArgumentException("Invalid environment setting. Valid values: prod, ver1, ver2, test") 364 | }; 365 | } 366 | 367 | private string GetTokenExchangeEndpoint(string environment) 368 | { 369 | return environment switch 370 | { 371 | "prod" => "https://platform.altinn.no/authentication/api/v1/exchange/maskinporten", 372 | "tt02" => "https://platform.tt02.altinn.no/authentication/api/v1/exchange/maskinporten", 373 | "at21" => "https://platform.at21.altinn.cloud/authentication/api/v1/exchange/maskinporten", 374 | "at22" => "https://platform.at22.altinn.cloud/authentication/api/v1/exchange/maskinporten", 375 | "at23" => "https://platform.at23.altinn.cloud/authentication/api/v1/exchange/maskinporten", 376 | "at24" => "https://platform.at24.altinn.cloud/authentication/api/v1/exchange/maskinporten", 377 | "yt01" => "https://platform.yt01.altinn.cloud/authentication/api/v1/exchange/maskinporten", 378 | // Supported for backward compatibility 379 | "ver1" => "https://platform.tt02.altinn.no/authentication/api/v1/exchange/maskinporten", 380 | "ver2" => "https://platform.tt02.altinn.no/authentication/api/v1/exchange/maskinporten", 381 | "test" => "https://platform.tt02.altinn.no/authentication/api/v1/exchange/maskinporten", 382 | _ => throw new ArgumentException("Invalid environment setting. Valid values: prod, tt02, at21, at22, at23, at24") 383 | }; 384 | } 385 | } 386 | } 387 | --------------------------------------------------------------------------------