From 506fb063ff67520a37bf5b87b4d222ceec91091d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Wed, 10 Jul 2024 17:20:23 +0200 Subject: [PATCH] Revert "Implement IMaskinportenClient interface (#669)" (#713) This reverts commit d6bfcccba026c40f62e7b110d2501fbb55910053. --- .../Extensions/HttpClientBuilderExtensions.cs | 34 -- .../Extensions/ServiceCollectionExtensions.cs | 61 ---- .../Extensions/WebHostBuilderExtensions.cs | 32 -- src/Altinn.App.Core/Altinn.App.Core.csproj | 1 - .../EformidlingStatusCheckEventHandler2.cs | 6 - .../Extensions/ServiceCollectionExtensions.cs | 17 - .../Converters/JsonWebKeyConverter.cs | 74 ----- .../MaskinportenDelegatingHandler.cs | 59 ---- .../MaskinportenAuthenticationException.cs | 18 -- .../MaskinportenConfigurationException.cs | 18 -- .../Exceptions/MaskinportenException.cs | 18 -- .../MaskinportenTokenExpiredException.cs | 18 -- .../MaskinportenUnsupportedTokenException.cs | 18 -- .../Maskinporten/IMaskinportenClient.cs | 33 -- .../Maskinporten/MaskinportenClient.cs | 273 ---------------- .../Models/MaskinportenSettings.cs | 244 --------------- .../Models/MaskinportenTokenResponse.cs | 71 ----- .../Maskinporten/Models/TokenCacheEntry.cs | 6 - .../Features/Telemetry.Maskinporten.cs | 56 ---- src/Altinn.App.Core/Features/Telemetry.cs | 1 - .../IMaskinportenTokenProvider.cs | 3 - .../Maskinporten/MaskinportenExtensions.cs | 3 - .../MaskinportenJwkTokenProvider.cs | 3 - .../Extensions/HttpClientExtensions.cs | 37 --- .../MaskinportenClientIntegrationTest.cs | 115 ------- .../Telemetry/TelemetryConfigurationTests.cs | 51 ++- .../TestUtils/AppBuilder.cs | 50 --- .../Altinn.App.Core.Tests.csproj | 6 +- .../MaskinportenDelegatingHandlerTest.cs | 63 ---- .../Maskinporten/MaskinportenClientTest.cs | 293 ------------------ .../Models/MaskinportenSettingsTest.cs | 237 -------------- .../Models/MaskinportenTokenResponseTest.cs | 35 --- .../Features/Maskinporten/TestHelpers.cs | 82 ----- 33 files changed, 42 insertions(+), 1994 deletions(-) delete mode 100644 src/Altinn.App.Api/Extensions/HttpClientBuilderExtensions.cs delete mode 100644 src/Altinn.App.Core/Features/Maskinporten/Converters/JsonWebKeyConverter.cs delete mode 100644 src/Altinn.App.Core/Features/Maskinporten/Delegates/MaskinportenDelegatingHandler.cs delete mode 100644 src/Altinn.App.Core/Features/Maskinporten/Exceptions/MaskinportenAuthenticationException.cs delete mode 100644 src/Altinn.App.Core/Features/Maskinporten/Exceptions/MaskinportenConfigurationException.cs delete mode 100644 src/Altinn.App.Core/Features/Maskinporten/Exceptions/MaskinportenException.cs delete mode 100644 src/Altinn.App.Core/Features/Maskinporten/Exceptions/MaskinportenTokenExpiredException.cs delete mode 100644 src/Altinn.App.Core/Features/Maskinporten/Exceptions/MaskinportenUnsupportedTokenException.cs delete mode 100644 src/Altinn.App.Core/Features/Maskinporten/IMaskinportenClient.cs delete mode 100644 src/Altinn.App.Core/Features/Maskinporten/MaskinportenClient.cs delete mode 100644 src/Altinn.App.Core/Features/Maskinporten/Models/MaskinportenSettings.cs delete mode 100644 src/Altinn.App.Core/Features/Maskinporten/Models/MaskinportenTokenResponse.cs delete mode 100644 src/Altinn.App.Core/Features/Maskinporten/Models/TokenCacheEntry.cs delete mode 100644 src/Altinn.App.Core/Features/Telemetry.Maskinporten.cs delete mode 100644 test/Altinn.App.Api.Tests/Extensions/HttpClientExtensions.cs delete mode 100644 test/Altinn.App.Api.Tests/Maskinporten/MaskinportenClientIntegrationTest.cs delete mode 100644 test/Altinn.App.Api.Tests/TestUtils/AppBuilder.cs delete mode 100644 test/Altinn.App.Core.Tests/Features/Maskinporten/Delegates/MaskinportenDelegatingHandlerTest.cs delete mode 100644 test/Altinn.App.Core.Tests/Features/Maskinporten/MaskinportenClientTest.cs delete mode 100644 test/Altinn.App.Core.Tests/Features/Maskinporten/Models/MaskinportenSettingsTest.cs delete mode 100644 test/Altinn.App.Core.Tests/Features/Maskinporten/Models/MaskinportenTokenResponseTest.cs delete mode 100644 test/Altinn.App.Core.Tests/Features/Maskinporten/TestHelpers.cs diff --git a/src/Altinn.App.Api/Extensions/HttpClientBuilderExtensions.cs b/src/Altinn.App.Api/Extensions/HttpClientBuilderExtensions.cs deleted file mode 100644 index 4d877b513..000000000 --- a/src/Altinn.App.Api/Extensions/HttpClientBuilderExtensions.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Altinn.App.Core.Features.Maskinporten; -using Altinn.App.Core.Features.Maskinporten.Delegates; - -namespace Altinn.App.Api.Extensions; - -/// -/// Altinn specific extensions for -/// -public static class HttpClientBuilderExtensions -{ - /// - /// - /// Sets up a middleware for the supplied , - /// which will inject an Authorization header with a Bearer token for all requests. - /// - /// - /// If your target API does not use this authentication scheme, you should consider implementing - /// directly and handling authorization details manually. - /// - /// - /// The Http client builder - /// The scope to claim authorization for with Maskinporten - /// Additional scopes as required - public static IHttpClientBuilder UseMaskinportenAuthorization( - this IHttpClientBuilder builder, - string scope, - params string[] additionalScopes - ) - { - var scopes = new[] { scope }.Concat(additionalScopes); - var factory = ActivatorUtilities.CreateFactory([typeof(IEnumerable),]); - return builder.AddHttpMessageHandler(provider => factory(provider, [scopes])); - } -} diff --git a/src/Altinn.App.Api/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Api/Extensions/ServiceCollectionExtensions.cs index 06f80aeaa..25398c146 100644 --- a/src/Altinn.App.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Api/Extensions/ServiceCollectionExtensions.cs @@ -7,8 +7,6 @@ using Altinn.App.Core.Constants; using Altinn.App.Core.Extensions; using Altinn.App.Core.Features; -using Altinn.App.Core.Features.Maskinporten; -using Altinn.App.Core.Features.Maskinporten.Models; using Altinn.Common.PEP.Authorization; using Altinn.Common.PEP.Clients; using AltinnCore.Authentication.JwtCookie; @@ -17,7 +15,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Options; using Microsoft.FeatureManagement; using Microsoft.IdentityModel.Tokens; using OpenTelemetry; @@ -68,7 +65,6 @@ IWebHostEnvironment env services.AddPlatformServices(config, env); services.AddAppServices(config, env); - services.AddMaskinportenClient(); services.ConfigureDataProtection(); var useOpenTelemetrySetting = config.GetValue("AppSettings:UseOpenTelemetry"); @@ -100,50 +96,6 @@ IWebHostEnvironment env services.AddSingleton(); } - /// - /// - /// Configures the service with a configuration object which will be static for the lifetime of the service. - /// - /// - /// If you have already provided a configuration, either manually or - /// implicitly via , this will be overridden. - /// - /// - /// The service collection - /// - /// Action delegate that provides configuration for the service - /// - public static IServiceCollection ConfigureMaskinportenClient( - this IServiceCollection services, - Action configureOptions - ) - { - services.AddOptions().Configure(configureOptions).ValidateDataAnnotations(); - - return services; - } - - /// - /// Adds a singleton service to the service collection. - /// Binds , either from `appsettings.json` or `maskinporten-settings.json` (if found). - ///

Note: This binding happens in . - ///
- /// The service collection - private static IServiceCollection AddMaskinportenClient(this IServiceCollection services) - { - if (services.GetOptionsDescriptor() is null) - { - services - .AddOptions() - .BindConfiguration("MaskinportenSettings") - .ValidateDataAnnotations(); - } - - services.AddSingleton(); - - return services; - } - /// /// Adds Application Insights to the service collection. /// @@ -460,19 +412,6 @@ private static void AddAntiforgery(IServiceCollection services) services.TryAddSingleton(); } - private static IServiceCollection RemoveOptions(this IServiceCollection services) - where TOptions : class - { - var descriptor = services.GetOptionsDescriptor(); - - if (descriptor is not null) - { - services.Remove(descriptor); - } - - return services; - } - private static (string? Key, string? ConnectionString) GetAppInsightsConfig( IConfiguration config, IHostEnvironment env diff --git a/src/Altinn.App.Api/Extensions/WebHostBuilderExtensions.cs b/src/Altinn.App.Api/Extensions/WebHostBuilderExtensions.cs index 52bf037b7..1bc5b0681 100644 --- a/src/Altinn.App.Api/Extensions/WebHostBuilderExtensions.cs +++ b/src/Altinn.App.Api/Extensions/WebHostBuilderExtensions.cs @@ -1,7 +1,4 @@ using Altinn.App.Core.Extensions; -using Altinn.App.Core.Features.Maskinporten; -using Altinn.App.Core.Features.Maskinporten.Models; -using Microsoft.Extensions.FileProviders; namespace Altinn.App.Api.Extensions; @@ -30,37 +27,8 @@ public static void ConfigureAppWebHost(this IWebHostBuilder builder, string[] ar } configBuilder.AddInMemoryCollection(config); - - configBuilder.AddMaskinportenSettingsFile(context); - configBuilder.LoadAppConfig(args); } ); } - - private static IConfigurationBuilder AddMaskinportenSettingsFile( - this IConfigurationBuilder configurationBuilder, - WebHostBuilderContext context - ) - { - string jsonProvidedPath = - context.Configuration.GetValue("MaskinportenSettingsFilepath") - ?? "/mnt/app-secrets/maskinporten-settings.json"; - string jsonAbsolutePath = Path.GetFullPath(jsonProvidedPath); - - if (File.Exists(jsonAbsolutePath)) - { - string jsonDir = Path.GetDirectoryName(jsonAbsolutePath) ?? string.Empty; - string jsonFile = Path.GetFileName(jsonAbsolutePath); - - configurationBuilder.AddJsonFile( - provider: new PhysicalFileProvider(jsonDir), - path: jsonFile, - optional: true, - reloadOnChange: true - ); - } - - return configurationBuilder; - } } diff --git a/src/Altinn.App.Core/Altinn.App.Core.csproj b/src/Altinn.App.Core/Altinn.App.Core.csproj index 294f9d80e..ba6b35513 100644 --- a/src/Altinn.App.Core/Altinn.App.Core.csproj +++ b/src/Altinn.App.Core/Altinn.App.Core.csproj @@ -19,7 +19,6 @@ - diff --git a/src/Altinn.App.Core/EFormidling/Implementation/EformidlingStatusCheckEventHandler2.cs b/src/Altinn.App.Core/EFormidling/Implementation/EformidlingStatusCheckEventHandler2.cs index 254c0ce00..687e47079 100644 --- a/src/Altinn.App.Core/EFormidling/Implementation/EformidlingStatusCheckEventHandler2.cs +++ b/src/Altinn.App.Core/EFormidling/Implementation/EformidlingStatusCheckEventHandler2.cs @@ -25,11 +25,7 @@ public class EformidlingStatusCheckEventHandler2 : IEventHandler private readonly IEFormidlingClient _eFormidlingClient; private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; - -#pragma warning disable CS0618 // 'member' is obsolete: private readonly IMaskinportenTokenProvider _maskinportenTokenProvider; -#pragma warning restore CS0618 - private readonly PlatformSettings _platformSettings; private readonly GeneralSettings _generalSettings; @@ -40,9 +36,7 @@ public EformidlingStatusCheckEventHandler2( IEFormidlingClient eFormidlingClient, IHttpClientFactory httpClientFactory, ILogger logger, -#pragma warning disable CS0618 // 'member' is obsolete: IMaskinportenTokenProvider maskinportenTokenProvider, -#pragma warning restore CS0618 IOptions platformSettings, IOptions generalSettings ) diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index 65bcb54c9..1826c4a29 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -58,7 +58,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Options; using Newtonsoft.Json.Linq; using IProcessEngine = Altinn.App.Core.Internal.Process.IProcessEngine; using IProcessReader = Altinn.App.Core.Internal.Process.IProcessReader; @@ -106,7 +105,6 @@ IWebHostEnvironment env #pragma warning restore CS0618 // Type or member is obsolete services.AddHttpClient(); services.AddHttpClient(); - services.AddHybridCache(); services.TryAddTransient(); services.TryAddTransient(); @@ -369,19 +367,4 @@ private static void AddFileValidatorServices(IServiceCollection services) services.TryAddTransient(); services.TryAddTransient(); } - - internal static IEnumerable GetOptionsDescriptors(this IServiceCollection services) - where TOptions : class - { - return services.Where(d => - d.ServiceType == typeof(IConfigureOptions) - || d.ServiceType == typeof(IOptionsChangeTokenSource) - ); - } - - internal static ServiceDescriptor? GetOptionsDescriptor(this IServiceCollection services) - where TOptions : class - { - return services.GetOptionsDescriptors().FirstOrDefault(); - } } diff --git a/src/Altinn.App.Core/Features/Maskinporten/Converters/JsonWebKeyConverter.cs b/src/Altinn.App.Core/Features/Maskinporten/Converters/JsonWebKeyConverter.cs deleted file mode 100644 index 90f5b20be..000000000 --- a/src/Altinn.App.Core/Features/Maskinporten/Converters/JsonWebKeyConverter.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using Altinn.App.Core.Features.Maskinporten.Exceptions; -using Altinn.App.Core.Features.Maskinporten.Models; -using Microsoft.IdentityModel.Tokens; - -namespace Altinn.App.Core.Features.Maskinporten.Converters; - -/// -/// Utility class that facilitates conversion -/// -internal static class JsonWebKeyConverter -{ - /// - /// Creates a instance from the supplied object. - /// - public static JsonWebKey FromJwkWrapper(JwkWrapper jwk) - { - // Validate - var validationResult = jwk.Validate(); - if (!validationResult.IsValid()) - { - throw new MaskinportenConfigurationException( - $"The MaskinportenSettings.{nameof(MaskinportenSettings.Jwk)} JsonWebKey is invalid after deserialization, not all required properties were found: {validationResult}" - ); - } - - return jwk.ToJsonWebKey(); - } - - /// - /// Creates a instance from the supplied object. - /// - public static JsonWebKey FromBase64String(string jwkBase64) - { - JwkWrapper jwk; - try - { - string decoded = Encoding.UTF8.GetString(System.Convert.FromBase64String(jwkBase64)); - var deserialized = - JsonSerializer.Deserialize(decoded) - ?? throw new MaskinportenConfigurationException( - $"Literal null value for property MaskinportenSettings.{nameof(MaskinportenSettings.JwkBase64)}." - ); - jwk = deserialized; - } - catch (JsonException e) - { - throw new MaskinportenConfigurationException( - $"Error parsing MaskinportenSettings.{nameof(MaskinportenSettings.JwkBase64)} JSON structure: {e.Message}", - e - ); - } - catch (Exception e) - { - throw new MaskinportenConfigurationException( - $"Error decoding MaskinportenSettings.{nameof(MaskinportenSettings.JwkBase64)} from base64: {e.Message}", - e - ); - } - - // Validate - var validationResult = jwk.Validate(); - if (!validationResult.IsValid()) - { - throw new MaskinportenConfigurationException( - $"The MaskinportenSettings.{nameof(MaskinportenSettings.Jwk)} JsonWebKey is invalid after deserialization, not all required properties were found: {validationResult}" - ); - } - - return jwk.ToJsonWebKey(); - } -} diff --git a/src/Altinn.App.Core/Features/Maskinporten/Delegates/MaskinportenDelegatingHandler.cs b/src/Altinn.App.Core/Features/Maskinporten/Delegates/MaskinportenDelegatingHandler.cs deleted file mode 100644 index e3ac0503d..000000000 --- a/src/Altinn.App.Core/Features/Maskinporten/Delegates/MaskinportenDelegatingHandler.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Net.Http.Headers; -using Altinn.App.Core.Features.Maskinporten.Exceptions; -using Microsoft.Extensions.Logging; - -namespace Altinn.App.Core.Features.Maskinporten.Delegates; - -/// -/// A middleware that provides authorization for all http requests -/// -internal sealed class MaskinportenDelegatingHandler : DelegatingHandler -{ - public IEnumerable Scopes { get; init; } - - private readonly ILogger _logger; - private readonly IMaskinportenClient _maskinportenClient; - - /// - /// Creates a new instance of . - /// - /// A list of scopes to claim authorization for with Maskinporten - /// A instance - /// Optional logger interface - public MaskinportenDelegatingHandler( - IEnumerable scopes, - IMaskinportenClient maskinportenClient, - ILogger logger - ) - { - Scopes = scopes; - _logger = logger; - _maskinportenClient = maskinportenClient; - } - - /// - protected override async Task SendAsync( - HttpRequestMessage request, - CancellationToken cancellationToken - ) - { - _logger.LogDebug("Executing custom `SendAsync` method; injecting authentication headers"); - - var auth = await _maskinportenClient.GetAccessToken(Scopes, cancellationToken); - if (!auth.TokenType.Equals(TokenTypes.Bearer, StringComparison.OrdinalIgnoreCase)) - { - throw new MaskinportenUnsupportedTokenException( - $"Unsupported token type received from Maskinporten: {auth.TokenType}" - ); - } - - request.Headers.Authorization = new AuthenticationHeaderValue(TokenTypes.Bearer, auth.AccessToken); - - return await base.SendAsync(request, cancellationToken); - } -} - -internal static class TokenTypes -{ - public const string Bearer = "Bearer"; -} diff --git a/src/Altinn.App.Core/Features/Maskinporten/Exceptions/MaskinportenAuthenticationException.cs b/src/Altinn.App.Core/Features/Maskinporten/Exceptions/MaskinportenAuthenticationException.cs deleted file mode 100644 index 7b258157c..000000000 --- a/src/Altinn.App.Core/Features/Maskinporten/Exceptions/MaskinportenAuthenticationException.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Altinn.App.Core.Features.Maskinporten.Exceptions; - -/// -/// An exception that indicates a problem with the authentication/authorization call to Maskinporten -/// -public sealed class MaskinportenAuthenticationException : MaskinportenException -{ - /// - public MaskinportenAuthenticationException() { } - - /// - public MaskinportenAuthenticationException(string? message) - : base(message) { } - - /// - public MaskinportenAuthenticationException(string? message, Exception? innerException) - : base(message, innerException) { } -} diff --git a/src/Altinn.App.Core/Features/Maskinporten/Exceptions/MaskinportenConfigurationException.cs b/src/Altinn.App.Core/Features/Maskinporten/Exceptions/MaskinportenConfigurationException.cs deleted file mode 100644 index adeecea8b..000000000 --- a/src/Altinn.App.Core/Features/Maskinporten/Exceptions/MaskinportenConfigurationException.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Altinn.App.Core.Features.Maskinporten.Exceptions; - -/// -/// An exception that indicates a missing or invalid `maskinporten-settings.json` file -/// -public sealed class MaskinportenConfigurationException : MaskinportenException -{ - /// - public MaskinportenConfigurationException() { } - - /// - public MaskinportenConfigurationException(string? message) - : base(message) { } - - /// - public MaskinportenConfigurationException(string? message, Exception? innerException) - : base(message, innerException) { } -} diff --git a/src/Altinn.App.Core/Features/Maskinporten/Exceptions/MaskinportenException.cs b/src/Altinn.App.Core/Features/Maskinporten/Exceptions/MaskinportenException.cs deleted file mode 100644 index 26c5b33ad..000000000 --- a/src/Altinn.App.Core/Features/Maskinporten/Exceptions/MaskinportenException.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Altinn.App.Core.Features.Maskinporten.Exceptions; - -/// -/// Generic Maskinporten related exception. Something went wrong, and it was related to Maskinporten. -/// -public abstract class MaskinportenException : Exception -{ - /// - protected MaskinportenException() { } - - /// - protected MaskinportenException(string? message) - : base(message) { } - - /// - protected MaskinportenException(string? message, Exception? innerException) - : base(message, innerException) { } -} diff --git a/src/Altinn.App.Core/Features/Maskinporten/Exceptions/MaskinportenTokenExpiredException.cs b/src/Altinn.App.Core/Features/Maskinporten/Exceptions/MaskinportenTokenExpiredException.cs deleted file mode 100644 index fc2be85c6..000000000 --- a/src/Altinn.App.Core/Features/Maskinporten/Exceptions/MaskinportenTokenExpiredException.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Altinn.App.Core.Features.Maskinporten.Exceptions; - -/// -/// An exception that indicates the access token has expired when it was in fact expected to be valid -/// -public sealed class MaskinportenTokenExpiredException : MaskinportenException -{ - /// - public MaskinportenTokenExpiredException() { } - - /// - public MaskinportenTokenExpiredException(string? message) - : base(message) { } - - /// - public MaskinportenTokenExpiredException(string? message, Exception? innerException) - : base(message, innerException) { } -} diff --git a/src/Altinn.App.Core/Features/Maskinporten/Exceptions/MaskinportenUnsupportedTokenException.cs b/src/Altinn.App.Core/Features/Maskinporten/Exceptions/MaskinportenUnsupportedTokenException.cs deleted file mode 100644 index 11e1cd8fd..000000000 --- a/src/Altinn.App.Core/Features/Maskinporten/Exceptions/MaskinportenUnsupportedTokenException.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Altinn.App.Core.Features.Maskinporten.Exceptions; - -/// -/// An exception that indicates an unsupported token type has been received from Maskinporten -/// -public class MaskinportenUnsupportedTokenException : MaskinportenException -{ - /// - public MaskinportenUnsupportedTokenException() { } - - /// - public MaskinportenUnsupportedTokenException(string? message) - : base(message) { } - - /// - public MaskinportenUnsupportedTokenException(string? message, Exception? innerException) - : base(message, innerException) { } -} diff --git a/src/Altinn.App.Core/Features/Maskinporten/IMaskinportenClient.cs b/src/Altinn.App.Core/Features/Maskinporten/IMaskinportenClient.cs deleted file mode 100644 index 254adfdc4..000000000 --- a/src/Altinn.App.Core/Features/Maskinporten/IMaskinportenClient.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Altinn.App.Core.Features.Maskinporten.Models; - -namespace Altinn.App.Core.Features.Maskinporten; - -/// -/// Contains logic for handling authorization requests with Maskinporten. -/// -public interface IMaskinportenClient -{ - /// - /// - /// Sends an authorization request to Maskinporten and retrieves a JWT Bearer token for successful requests. - /// - /// - /// Will cache tokens per scope, for the lifetime duration as defined in the token Maskinporten token payload, - /// which means this method is safe to call in a loop or concurrent environment without encountering rate concerns. - /// - /// - /// A list of scopes to claim authorization for with Maskinporten. - /// An optional cancellation token to be forwarded to internal http calls. - /// A which contains an access token, amongst other things. - /// - /// Authentication failed. This could be caused by an authentication/authorization issue or a myriad of other circumstances. - /// - /// - /// The Maskinporten configuration is incomplete or invalid. Very possibly because of a missing or corrupt maskinporten-settings.json file. - /// - /// The token received from Maskinporten has already expired. - public Task GetAccessToken( - IEnumerable scopes, - CancellationToken cancellationToken = default - ); -} diff --git a/src/Altinn.App.Core/Features/Maskinporten/MaskinportenClient.cs b/src/Altinn.App.Core/Features/Maskinporten/MaskinportenClient.cs deleted file mode 100644 index bb8115136..000000000 --- a/src/Altinn.App.Core/Features/Maskinporten/MaskinportenClient.cs +++ /dev/null @@ -1,273 +0,0 @@ -using System.Text.Json; -using Altinn.App.Core.Features.Maskinporten.Exceptions; -using Altinn.App.Core.Features.Maskinporten.Models; -using Microsoft.Extensions.Caching.Hybrid; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.JsonWebTokens; -using Microsoft.IdentityModel.Tokens; - -namespace Altinn.App.Core.Features.Maskinporten; - -/// -public sealed class MaskinportenClient : IMaskinportenClient -{ - /// - /// The margin to take into consideration when determining if a token has expired (seconds). - /// This value represents the worst-case latency scenario for outbound connections carrying the access token. - /// - internal const int TokenExpirationMargin = 30; - - private const string CacheKeySalt = "maskinportenScope-"; - private static readonly HybridCacheEntryOptions _defaultCacheExpiration = CacheExpiry(TimeSpan.FromSeconds(60)); - private readonly ILogger _logger; - private readonly IOptionsMonitor _options; - private readonly IHttpClientFactory _httpClientFactory; - private readonly TimeProvider _timeprovider; - private readonly HybridCache _tokenCache; - private readonly Telemetry? _telemetry; - - /// - /// Instantiates a new object. - /// - /// Maskinporten settings. - /// HttpClient factory. - /// Token cache store. - /// Logger interface. - /// Optional TimeProvider implementation. - /// Optional telemetry service. - public MaskinportenClient( - IOptionsMonitor options, - IHttpClientFactory httpClientFactory, - HybridCache tokenCache, - ILogger logger, - TimeProvider? timeProvider = null, - Telemetry? telemetry = null - ) - { - _options = options; - _telemetry = telemetry; - _timeprovider = timeProvider ?? TimeProvider.System; - _logger = logger; - _httpClientFactory = httpClientFactory; - _tokenCache = tokenCache; - } - - /// - public async Task GetAccessToken( - IEnumerable scopes, - CancellationToken cancellationToken = default - ) - { - string formattedScopes = FormattedScopes(scopes); - string cacheKey = $"{CacheKeySalt}_{formattedScopes}"; - DateTimeOffset referenceTime = _timeprovider.GetUtcNow(); - - _telemetry?.StartGetAccessTokenActivity(_options.CurrentValue.ClientId, formattedScopes); - - var result = await _tokenCache.GetOrCreateAsync( - cacheKey, - async cancel => - { - // Fetch token - var token = await HandleMaskinportenAuthentication(formattedScopes, cancel); - var now = _timeprovider.GetUtcNow(); - var cacheExpiry = referenceTime.AddSeconds(token.ExpiresIn - TokenExpirationMargin); - if (cacheExpiry <= now) - { - throw new MaskinportenTokenExpiredException( - $"Access token cannot be used because it has a calculated expiration in the past (taking into account a margin of {TokenExpirationMargin} seconds): {token}" - ); - } - - // Wrap and return - return new TokenCacheEntry( - Token: token, - Expiration: cacheExpiry - referenceTime, - HasSetExpiration: false - ); - }, - token: cancellationToken, - options: _defaultCacheExpiration - ); - - // Update cache with token expiration if applicable - if (result.HasSetExpiration is false) - { - result = result with { HasSetExpiration = true }; - await _tokenCache.SetAsync( - cacheKey, - result, - options: CacheExpiry(result.Expiration), - token: cancellationToken - ); - } - - return result.Token; - } - - /// - /// Handles the sending of grant requests to Maskinporten and parsing the returned response - /// - /// A single space-separated string containing the scopes to authorize for. - /// An optional cancellation token. - /// - /// - private async Task HandleMaskinportenAuthentication( - string formattedScopes, - CancellationToken cancellationToken = default - ) - { - try - { - string jwt = GenerateJwtGrant(formattedScopes); - FormUrlEncodedContent payload = GenerateAuthenticationPayload(jwt); - - _logger.LogDebug( - "Sending grant request to Maskinporten: {GrantRequest}", - await payload.ReadAsStringAsync(cancellationToken) - ); - - string tokenAuthority = _options.CurrentValue.Authority.Trim('/'); - using HttpClient client = _httpClientFactory.CreateClient(); - using HttpResponseMessage response = await client.PostAsync( - $"{tokenAuthority}/token", - payload, - cancellationToken - ); - MaskinportenTokenResponse token = await ParseServerResponse(response, cancellationToken); - - _logger.LogDebug("Token retrieved successfully"); - return token; - } - catch (MaskinportenException) - { - throw; - } - catch (Exception e) - { - throw new MaskinportenAuthenticationException($"Authentication with Maskinporten failed: {e.Message}", e); - } - } - - /// - /// Generates a JWT grant for the supplied scope claims along with the pre-configured client id and private key. - /// - /// A space-separated list of scopes to make a claim for. - /// - /// - internal string GenerateJwtGrant(string formattedScopes) - { - MaskinportenSettings? settings; - try - { - settings = _options.CurrentValue; - } - catch (OptionsValidationException e) - { - throw new MaskinportenConfigurationException( - $"Error reading MaskinportenSettings from the current app configuration", - e - ); - } - - var now = _timeprovider.GetUtcNow(); - var expiry = now.AddMinutes(2); - var jwtDescriptor = new SecurityTokenDescriptor - { - Issuer = settings.ClientId, - Audience = settings.Authority, - IssuedAt = now.UtcDateTime, - Expires = expiry.UtcDateTime, - SigningCredentials = new SigningCredentials(settings.GetJsonWebKey(), SecurityAlgorithms.RsaSha256), - Claims = new Dictionary { ["scope"] = formattedScopes, ["jti"] = Guid.NewGuid().ToString() } - }; - - return new JsonWebTokenHandler().CreateToken(jwtDescriptor); - } - - /// - /// - /// Generates an authentication payload from the supplied JWT (see ). - /// - /// - /// This payload needs to be a object with some precise parameters, - /// as per the docs.. - /// - /// - /// The JWT token generated by . - internal static FormUrlEncodedContent GenerateAuthenticationPayload(string jwtAssertion) - { - return new FormUrlEncodedContent( - new Dictionary - { - ["grant_type"] = "urn:ietf:params:oauth:grant-type:jwt-bearer", - ["assertion"] = jwtAssertion - } - ); - } - - /// - /// Parses the Maskinporten server response and deserializes the JSON body. - /// - /// The server response. - /// An optional cancellation token. - /// A for successful requests. - /// Authentication failed. - /// This could be caused by an authentication/authorization issue or a myriad of tother circumstances. - internal static async Task ParseServerResponse( - HttpResponseMessage httpResponse, - CancellationToken cancellationToken = default - ) - { - try - { - string content = await httpResponse.Content.ReadAsStringAsync(cancellationToken); - - try - { - if (!httpResponse.IsSuccessStatusCode) - { - throw new MaskinportenAuthenticationException( - $"Maskinporten authentication failed with status code {(int)httpResponse.StatusCode} ({httpResponse.StatusCode}): {content}" - ); - } - - return JsonSerializer.Deserialize(content) - ?? throw new JsonException("JSON body is null"); - } - catch (JsonException e) - { - throw new MaskinportenAuthenticationException( - $"Maskinporten replied with invalid JSON formatting: {content}", - e - ); - } - } - catch (MaskinportenException) - { - throw; - } - catch (Exception e) - { - throw new MaskinportenAuthenticationException($"Authentication with Maskinporten failed: {e.Message}", e); - } - } - - /// - /// Formats a list of scopes according to the expected formatting (space-delimited). - /// See the docs for more information. - /// - /// A collection of scopes. - /// A single string containing the supplied scopes. - internal static string FormattedScopes(IEnumerable scopes) => string.Join(" ", scopes); - - internal static HybridCacheEntryOptions CacheExpiry(TimeSpan localExpiry, TimeSpan? overallExpiry = null) - { - return new HybridCacheEntryOptions - { - LocalCacheExpiration = localExpiry, - Expiration = overallExpiry ?? localExpiry - }; - } -} diff --git a/src/Altinn.App.Core/Features/Maskinporten/Models/MaskinportenSettings.cs b/src/Altinn.App.Core/Features/Maskinporten/Models/MaskinportenSettings.cs deleted file mode 100644 index db52a6240..000000000 --- a/src/Altinn.App.Core/Features/Maskinporten/Models/MaskinportenSettings.cs +++ /dev/null @@ -1,244 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Text.Json.Serialization; -using Altinn.App.Core.Features.Maskinporten.Exceptions; -using Microsoft.IdentityModel.Tokens; -using JsonWebKeyConverter = Altinn.App.Core.Features.Maskinporten.Converters.JsonWebKeyConverter; - -namespace Altinn.App.Core.Features.Maskinporten.Models; - -/// -/// -/// A configuration object that represents all required Maskinporten authentication settings. -/// -/// -/// Typically serialized as `maskinporten-settings.json` and injected in the runtime. -/// -/// -public sealed record MaskinportenSettings -{ - /// - /// The Maskinporten authority/audience to use for authentication and authorization. - /// More info about environments and URIs in the docs. - /// - [Required] - [JsonPropertyName("authority")] - public required string Authority { get; set; } - - /// - /// The client ID which has been registered with Maskinporten. Typically a uuid4 string. - /// - [Required] - [JsonPropertyName("clientId")] - public required string ClientId { get; set; } - - /// - /// The private key used to authenticate with Maskinporten, in JWK format. - /// - [JsonPropertyName("jwk")] - public JwkWrapper? Jwk { get; set; } - - /// - /// The private key used to authenticate with Maskinporten, in Base64 encoded JWK format. - /// - [JsonPropertyName("jwkBase64")] - public string? JwkBase64 { get; set; } - - private JsonWebKey? _jsonWebKey; - - /// - /// The parsed version of / as a instance. - /// - public JsonWebKey GetJsonWebKey() - { - if (_jsonWebKey is not null) - { - return _jsonWebKey; - } - - _jsonWebKey = ConvertJwk(); - return _jsonWebKey; - } - - /// - /// Convert / to a instance. Caches the result. - /// - internal JsonWebKey ConvertJwk() - { - JsonWebKey? jwk = null; - - // Got jwk - if (Jwk is not null) - { - jwk = JsonWebKeyConverter.FromJwkWrapper(Jwk); - } - - // Got possible base64 encoded string - if (!string.IsNullOrWhiteSpace(JwkBase64)) - { - jwk = JsonWebKeyConverter.FromBase64String(JwkBase64); - } - - // Got nothing - if (jwk is null) - { - throw new MaskinportenConfigurationException( - $"No private key configured, neither MaskinportenSettings.{nameof(Jwk)} nor MaskinportenSettings.{nameof(JwkBase64)} was supplied." - ); - } - - return jwk; - } -} - -/// -/// Serialization wrapper for a JsonWebKey object -/// -public record JwkWrapper -{ - /// - /// Key type - /// - [JsonPropertyName("kty")] - public string? Kty { get; init; } - - /// - /// Public key usage - /// - [JsonPropertyName("use")] - public string? Use { get; init; } - - /// - /// Key ID - /// - [JsonPropertyName("kid")] - public string? Kid { get; init; } - - /// - /// Algorithm - /// - [JsonPropertyName("alg")] - public string? Alg { get; init; } - - /// - /// Modulus - /// - [JsonPropertyName("n")] - public string? N { get; init; } - - /// - /// Exponent - /// - [JsonPropertyName("e")] - public string? E { get; init; } - - /// - /// Private exponent - /// - [JsonPropertyName("d")] - public string? D { get; init; } - - /// - /// First prime factor - /// - [JsonPropertyName("p")] - public string? P { get; init; } - - /// - /// Second prime factor - /// - [JsonPropertyName("q")] - public string? Q { get; init; } - - /// - /// First CRT coefficient - /// - [JsonPropertyName("qi")] - public string? Qi { get; init; } - - /// - /// First factor CRT exponent - /// - [JsonPropertyName("dp")] - public string? Dp { get; init; } - - /// - /// Second factor CRT exponent - /// - [JsonPropertyName("dq")] - public string? Dq { get; init; } - - /// - /// Validates the contents of this JWK - /// - public ValidationResult Validate() - { - var props = new Dictionary - { - [nameof(Kty)] = Kty, - [nameof(Use)] = Use, - [nameof(Kid)] = Kid, - [nameof(Alg)] = Alg, - [nameof(N)] = N, - [nameof(E)] = E, - [nameof(D)] = D, - [nameof(P)] = P, - [nameof(Q)] = Q, - [nameof(Qi)] = Qi, - [nameof(Dp)] = Dp, - [nameof(Dq)] = Dq - }; - - return new ValidationResult - { - InvalidProperties = props.Where(x => string.IsNullOrWhiteSpace(x.Value)).Select(x => x.Key).ToList() - }; - } - - /// - /// A instance containing the component data from this record - /// - public JsonWebKey ToJsonWebKey() - { - return new JsonWebKey - { - Kty = Kty, - Use = Use, - Kid = Kid, - Alg = Alg, - N = N, - E = E, - D = D, - P = P, - Q = Q, - QI = Qi, - DP = Dp, - DQ = Dq - }; - } - - /// - /// A record that holds the result of a call - /// - public readonly record struct ValidationResult - { - /// - /// A collection of properties that are considered to be invalid - /// - public IEnumerable? InvalidProperties { get; init; } - - /// - /// Shorthand: Is the object in a valid state? - /// - public bool IsValid() => InvalidProperties.IsNullOrEmpty(); - - /// - /// Helpful summary of the result - /// - public override string ToString() - { - return IsValid() - ? "All properties are valid" - : $"The following required properties are empty: {string.Join(", ", InvalidProperties ?? [])}"; - } - } -} diff --git a/src/Altinn.App.Core/Features/Maskinporten/Models/MaskinportenTokenResponse.cs b/src/Altinn.App.Core/Features/Maskinporten/Models/MaskinportenTokenResponse.cs deleted file mode 100644 index 4a9f63593..000000000 --- a/src/Altinn.App.Core/Features/Maskinporten/Models/MaskinportenTokenResponse.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System.ComponentModel; -using System.Text.Json.Serialization; -using System.Text.RegularExpressions; - -namespace Altinn.App.Core.Features.Maskinporten.Models; - -/// -/// The response received from Maskinporten after a successful grant request. -/// -[ImmutableObject(true)] -public sealed partial record MaskinportenTokenResponse -{ - private static readonly Regex _jwtStructurePattern = JwtRegexFactory(); - - /// - /// The JWT access token to be used in the Authorization header for downstream requests. - /// - [JsonPropertyName("access_token")] - public required string AccessToken { get; init; } - - /// - /// The type of JWT received. In this context, the value is always `Bearer`. - /// - [JsonPropertyName("token_type")] - public required string TokenType { get; init; } - - /// - /// The number of seconds until token expiry. Typically set to 120 = 2 minutes. - /// - [JsonPropertyName("expires_in")] - public required int ExpiresIn { get; init; } - - /// - /// The scope(s) associated with the authorization token (). - /// - [JsonPropertyName("scope")] - public required string Scope { get; init; } - - /// - /// Convenience conversion of to an actual instant in time. - /// - public DateTime ExpiresAt => _createdAt.AddSeconds(ExpiresIn); - - /// - /// Internal tracker used by to calculate an expiry . - /// - private readonly DateTime _createdAt = DateTime.UtcNow; - - /// - /// Is the token expired? - /// - public bool IsExpired() - { - return ExpiresAt < DateTime.UtcNow; - } - - /// - /// Stringifies the content of this instance, while masking the JWT signature part of - /// - public override string ToString() - { - var accessTokenMatch = _jwtStructurePattern.Match(AccessToken); - var maskedToken = accessTokenMatch.Success - ? $"{accessTokenMatch.Groups[1]}.{accessTokenMatch.Groups[2]}.xxx" - : ""; - return $"{nameof(AccessToken)}: {maskedToken}, {nameof(TokenType)}: {TokenType}, {nameof(Scope)}: {Scope}, {nameof(ExpiresIn)}: {ExpiresIn}, {nameof(ExpiresAt)}: {ExpiresAt}"; - } - - [GeneratedRegex(@"^(.+)\.(.+)\.(.+)$", RegexOptions.Multiline)] - private static partial Regex JwtRegexFactory(); -} diff --git a/src/Altinn.App.Core/Features/Maskinporten/Models/TokenCacheEntry.cs b/src/Altinn.App.Core/Features/Maskinporten/Models/TokenCacheEntry.cs deleted file mode 100644 index f71ead563..000000000 --- a/src/Altinn.App.Core/Features/Maskinporten/Models/TokenCacheEntry.cs +++ /dev/null @@ -1,6 +0,0 @@ -using System.ComponentModel; - -namespace Altinn.App.Core.Features.Maskinporten.Models; - -[ImmutableObject(true)] -internal sealed record TokenCacheEntry(MaskinportenTokenResponse Token, TimeSpan Expiration, bool HasSetExpiration); diff --git a/src/Altinn.App.Core/Features/Telemetry.Maskinporten.cs b/src/Altinn.App.Core/Features/Telemetry.Maskinporten.cs deleted file mode 100644 index 1050a1987..000000000 --- a/src/Altinn.App.Core/Features/Telemetry.Maskinporten.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Diagnostics; -using NetEscapades.EnumGenerators; -using static Altinn.App.Core.Features.Telemetry.Maskinporten; -using Tag = System.Collections.Generic.KeyValuePair; - -namespace Altinn.App.Core.Features; - -partial class Telemetry -{ - private void InitMaskinporten(InitContext context) - { - InitMetricCounter( - context, - MetricNameTokenRequest, - init: static m => - { - foreach (var result in RequestResultExtensions.GetValues()) - { - m.Add(0, new Tag(InternalLabels.Result, result.ToStringFast())); - } - } - ); - } - - internal Activity? StartGetAccessTokenActivity(string clientId, string scopes) - { - var activity = ActivitySource.StartActivity("Maskinporten.GetAccessToken"); - activity?.SetTag("maskinporten.scopes", scopes); - activity?.SetTag("maskinporten.client_id", clientId); - return activity; - } - - internal void RecordMaskinportenTokenRequest(RequestResult result) - { - _counters[MetricNameTokenRequest].Add(1, new Tag(InternalLabels.Result, result.ToStringFast())); - } - - internal static class Maskinporten - { - internal static readonly string MetricNameTokenRequest = Metrics.CreateLibName("maskinporten_token_requests"); - - [EnumExtensions] - internal enum RequestResult - { - [Display(Name = "cached")] - Cached, - - [Display(Name = "new")] - New, - - [Display(Name = "error")] - Error - } - } -} diff --git a/src/Altinn.App.Core/Features/Telemetry.cs b/src/Altinn.App.Core/Features/Telemetry.cs index d389fb79c..1bbe6c2d0 100644 --- a/src/Altinn.App.Core/Features/Telemetry.cs +++ b/src/Altinn.App.Core/Features/Telemetry.cs @@ -75,7 +75,6 @@ internal void Init() InitNotifications(context); InitProcesses(context); InitValidation(context); - InitMaskinporten(context); // NOTE: This Telemetry class is registered as a singleton // Metrics could be kept in fields of the respective objects that use them for instrumentation diff --git a/src/Altinn.App.Core/Internal/Maskinporten/IMaskinportenTokenProvider.cs b/src/Altinn.App.Core/Internal/Maskinporten/IMaskinportenTokenProvider.cs index 4e4c1fa86..7dfaf18f1 100644 --- a/src/Altinn.App.Core/Internal/Maskinporten/IMaskinportenTokenProvider.cs +++ b/src/Altinn.App.Core/Internal/Maskinporten/IMaskinportenTokenProvider.cs @@ -5,9 +5,6 @@ namespace Altinn.App.Core.Internal.Maskinporten; /// The provider is used by client implementations that needs the Maskinporten token in requests /// against other systems. /// -[Obsolete( - "Use Altinn.App.Api.ServiceCollectionExtensions.ConfigureMaskinportenClient instead. This interface will be removed in V9." -)] public interface IMaskinportenTokenProvider { /// diff --git a/src/Altinn.App.Core/Internal/Maskinporten/MaskinportenExtensions.cs b/src/Altinn.App.Core/Internal/Maskinporten/MaskinportenExtensions.cs index ee27d621c..7bc8f6962 100644 --- a/src/Altinn.App.Core/Internal/Maskinporten/MaskinportenExtensions.cs +++ b/src/Altinn.App.Core/Internal/Maskinporten/MaskinportenExtensions.cs @@ -16,9 +16,6 @@ public static class MaskinportenExtensions /// The Jwk is fetched from the secret store using the provided secretKeyName. /// When using this locally the secret should be fetched from the local secret store using dotnet user-secrets. /// - [Obsolete( - "Use Altinn.App.Api.ServiceCollectionExtensions.ConfigureMaskinportenClient instead. This method will be removed in V9." - )] public static IServiceCollection AddMaskinportenJwkTokenProvider( this IServiceCollection services, string secretKeyName diff --git a/src/Altinn.App.Core/Internal/Maskinporten/MaskinportenJwkTokenProvider.cs b/src/Altinn.App.Core/Internal/Maskinporten/MaskinportenJwkTokenProvider.cs index 3462bad3e..48b659124 100644 --- a/src/Altinn.App.Core/Internal/Maskinporten/MaskinportenJwkTokenProvider.cs +++ b/src/Altinn.App.Core/Internal/Maskinporten/MaskinportenJwkTokenProvider.cs @@ -9,9 +9,6 @@ namespace Altinn.App.Core.Internal.Maskinporten; /// /// Defines the implementation of a Maskinporten token provider using JWK as the authentication method. /// -[Obsolete( - "Use Altinn.App.Api.ServiceCollectionExtensions.ConfigureMaskinportenClient instead. This service will be removed in V9." -)] public class MaskinportenJwkTokenProvider : IMaskinportenTokenProvider { private readonly IMaskinportenService _maskinportenService; diff --git a/test/Altinn.App.Api.Tests/Extensions/HttpClientExtensions.cs b/test/Altinn.App.Api.Tests/Extensions/HttpClientExtensions.cs deleted file mode 100644 index d58ac29f3..000000000 --- a/test/Altinn.App.Api.Tests/Extensions/HttpClientExtensions.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Reflection; - -namespace Altinn.App.Api.Tests.Extensions; - -public static class HttpClientExtensions -{ - public static T? GetDelegatingHandler(this HttpClient httpClient) - where T : class - { - ArgumentNullException.ThrowIfNull(httpClient); - - var internalHandlerField = typeof(HttpMessageInvoker).GetField( - "_handler", - BindingFlags.NonPublic | BindingFlags.Instance - ); - - Assert.NotNull(internalHandlerField); - - var delegatingHandler = internalHandlerField.GetValue(httpClient) as DelegatingHandler; - while (delegatingHandler is not null) - { - if (delegatingHandler.GetType() == typeof(T)) - { - return delegatingHandler as T; - } - delegatingHandler = delegatingHandler.InnerHandler as DelegatingHandler; - } - - return null; - } - - public static bool ContainsDelegatingHandler(this HttpClient httpClient) - where T : DelegatingHandler - { - return httpClient.GetDelegatingHandler() is not null; - } -} diff --git a/test/Altinn.App.Api.Tests/Maskinporten/MaskinportenClientIntegrationTest.cs b/test/Altinn.App.Api.Tests/Maskinporten/MaskinportenClientIntegrationTest.cs deleted file mode 100644 index 5e8a0f352..000000000 --- a/test/Altinn.App.Api.Tests/Maskinporten/MaskinportenClientIntegrationTest.cs +++ /dev/null @@ -1,115 +0,0 @@ -using Altinn.App.Api.Extensions; -using Altinn.App.Api.Tests.Extensions; -using Altinn.App.Api.Tests.TestUtils; -using Altinn.App.Core.Extensions; -using Altinn.App.Core.Features.Maskinporten; -using Altinn.App.Core.Features.Maskinporten.Delegates; -using Altinn.App.Core.Features.Maskinporten.Models; -using FluentAssertions; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; - -namespace Altinn.App.Api.Tests.Maskinporten; - -public class MaskinportenClientIntegrationTests -{ - [Fact] - public void ConfigureAppWebHost_AddsMaskinportenService() - { - var app = AppBuilder.Build(); - app.Services.GetServices().Should().HaveCount(1); - } - - [Fact] - public void ConfigureMaskinportenClient_OverridesDefaultMaskinportenConfiguration() - { - // Arrange - var clientId = "the-client-id"; - var authority = "https://maskinporten.dev/"; - - // Act - var app = AppBuilder.Build(registerCustomAppServices: services => - { - services.ConfigureMaskinportenClient(config => - { - config.ClientId = clientId; - config.Authority = authority; - config.JwkBase64 = "gibberish"; - }); - }); - - // Assert - var optionsMonitor = app.Services.GetRequiredService>(); - Assert.NotNull(optionsMonitor); - - var settings = optionsMonitor.CurrentValue; - Assert.NotNull(settings); - - settings.ClientId.Should().Be(clientId); - settings.Authority.Should().Be(authority); - } - - [Fact] - public void ConfigureMaskinportenClient_LastConfigurationOverwritesOthers() - { - // Arrange - var services = new ServiceCollection(); - var clientId = "the-client-id"; - var authority = "https://maskinporten.dev/"; - - // Act - services.ConfigureMaskinportenClient(config => - { - config.ClientId = "this should be overwritten"; - config.Authority = "ditto"; - config.JwkBase64 = "gibberish"; - }); - services.ConfigureMaskinportenClient(config => - { - config.ClientId = clientId; - config.Authority = authority; - config.JwkBase64 = "gibberish"; - }); - - // Assert - var serviceProvider = services.BuildServiceProvider(); - var optionsMonitor = serviceProvider.GetRequiredService>(); - Assert.NotNull(optionsMonitor); - - var settings = optionsMonitor.CurrentValue; - Assert.NotNull(settings); - - settings.ClientId.Should().Be(clientId); - settings.Authority.Should().Be(authority); - } - - [Theory] - [InlineData("client1", "scope1")] - [InlineData("client2", "scope1", "scope2", "scope3")] - public void UseMaskinportenAuthorization_AddsHandler_BindsToSpecifiedClient( - string scope, - params string[] additionalScopes - ) - { - // Arrange - var app = AppBuilder.Build(registerCustomAppServices: services => - services.AddHttpClient().UseMaskinportenAuthorization(scope, additionalScopes) - ); - - // Act - var client = app.Services.GetRequiredService(); - - // Assert - Assert.NotNull(client); - var delegatingHandler = client.HttpClient.GetDelegatingHandler(); - Assert.NotNull(delegatingHandler); - var inputScopes = new[] { scope }.Concat(additionalScopes); - delegatingHandler.Scopes.Should().BeEquivalentTo(inputScopes); - } - - private sealed class DummyHttpClient(HttpClient client) - { - public HttpClient HttpClient { get; set; } = client; - } -} diff --git a/test/Altinn.App.Api.Tests/Telemetry/TelemetryConfigurationTests.cs b/test/Altinn.App.Api.Tests/Telemetry/TelemetryConfigurationTests.cs index caa243c60..1a4f4a5a3 100644 --- a/test/Altinn.App.Api.Tests/Telemetry/TelemetryConfigurationTests.cs +++ b/test/Altinn.App.Api.Tests/Telemetry/TelemetryConfigurationTests.cs @@ -1,6 +1,5 @@ using System.Diagnostics.Tracing; using System.Reflection; -using Altinn.App.Api.Tests.TestUtils; using Altinn.App.Core.Features; using Microsoft.ApplicationInsights; using Microsoft.ApplicationInsights.Channel; @@ -151,7 +150,7 @@ public async Task AppInsights_Registers_Correctly() ) .Build(); - Altinn.App.Api.Extensions.ServiceCollectionExtensions.AddAltinnAppServices(services, config, env); + Extensions.ServiceCollectionExtensions.AddAltinnAppServices(services, config, env); services.AddApplicationInsightsTelemetryProcessor(); await using (var sp = services.BuildServiceProvider()) @@ -206,7 +205,7 @@ public async Task KeyedServices_Produces_Error_Diagnostics() // Hopefully we can remove all this soon services.AddKeyedSingleton("test"); - Altinn.App.Api.Extensions.ServiceCollectionExtensions.AddAltinnAppServices(services, config, env); + Extensions.ServiceCollectionExtensions.AddAltinnAppServices(services, config, env); await using (var sp = services.BuildServiceProvider()) { @@ -221,6 +220,36 @@ public async Task KeyedServices_Produces_Error_Diagnostics() Assert.NotEmpty(events.Where(e => errorLevels.Contains(e.Level))); } + private WebApplication BuildApp( + IEnumerable> configData, + Action? registerCustomAppServices = null + ) + { + // Here we follow the order of operations currently present in the Program.cs generated by the template for apps, + // which is the following: + + // 0. WebApplication.CreateBuilder() + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Environment.EnvironmentName = "Development"; + builder.Configuration.AddInMemoryCollection(configData); + // 1. AddAltinnAppControllersWithViews + Extensions.ServiceCollectionExtensions.AddAltinnAppControllersWithViews(builder.Services); + // 2. RegisterCustomAppServices + registerCustomAppServices?.Invoke(builder.Services); + // 3. AddAltinnAppServices + Extensions.ServiceCollectionExtensions.AddAltinnAppServices( + builder.Services, + builder.Configuration, + builder.Environment + ); + // 4. ConfigureAppWebHost + Extensions.WebHostBuilderExtensions.ConfigureAppWebHost(builder.WebHost, []); + // 5. UseAltinnAppCommonConfiguration + var app = builder.Build(); + Extensions.WebApplicationBuilderExtensions.UseAltinnAppCommonConfiguration(app); + return app; + } + [Fact] public async Task OpenTelemetry_Registers_Correctly_When_Enabled() { @@ -230,7 +259,7 @@ public async Task OpenTelemetry_Registers_Correctly_When_Enabled() new("AppSettings:UseOpenTelemetry", "true"), ]; Telemetry? telemetry = null; - await using (var app = AppBuilder.Build(configData: configData)) + await using (var app = BuildApp(configData)) { var telemetryClient = app.Services.GetService(); Assert.Null(telemetryClient); @@ -247,7 +276,7 @@ public async Task OpenTelemetry_Registers_Correctly_When_Enabled() public async Task OpenTelemetry_Does_Not_Register_By_Default() { List> configData = [new("ApplicationInsights:InstrumentationKey", "test"),]; - await using (var app = AppBuilder.Build(configData: configData)) + await using (var app = BuildApp(configData)) { var telemetryClient = app.Services.GetService(); Assert.NotNull(telemetryClient); @@ -265,7 +294,7 @@ public async Task OpenTelemetry_Development_Default_Sampler_Is_AlwaysOnSampler() new("ApplicationInsights:InstrumentationKey", "test"), new("AppSettings:UseOpenTelemetry", "true"), ]; - await using var app = AppBuilder.Build(configData: configData); + await using var app = BuildApp(configData); var traceProvider = app.Services.GetRequiredService(); @@ -281,7 +310,7 @@ public async Task OpenTelemetry_Development_Default_MetricReaderOptions() new("ApplicationInsights:InstrumentationKey", "test"), new("AppSettings:UseOpenTelemetry", "true"), ]; - await using var app = AppBuilder.Build(configData: configData); + await using var app = BuildApp(configData); var options = app.Services.GetRequiredService>().Value; @@ -298,8 +327,8 @@ public async Task OpenTelemetry_Sampler_Override_Is_Possible() new("AppSettings:UseOpenTelemetry", "true"), ]; var samplerToUse = new ParentBasedSampler(new AlwaysOnSampler()); - await using var app = AppBuilder.Build( - configData: configData, + await using var app = BuildApp( + configData, registerCustomAppServices: services => { services.ConfigureOpenTelemetryTracerProvider(builder => @@ -326,8 +355,8 @@ public async Task OpenTelemetry_MetricReaderOptions_Override_Is_Possible_Through var intervalToUse = 5_000; var timeoutToUse = 4_000; - await using var app = AppBuilder.Build( - configData: configData, + await using var app = BuildApp( + configData, registerCustomAppServices: services => { services.Configure(options => diff --git a/test/Altinn.App.Api.Tests/TestUtils/AppBuilder.cs b/test/Altinn.App.Api.Tests/TestUtils/AppBuilder.cs deleted file mode 100644 index 6fc035323..000000000 --- a/test/Altinn.App.Api.Tests/TestUtils/AppBuilder.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace Altinn.App.Api.Tests.TestUtils; - -public static class AppBuilder -{ - public static WebApplication Build( - WebApplicationBuilder? builder = default, - IEnumerable>? configData = default, - Action? registerCustomAppServices = default - ) - { - // Here we follow the order of operations currently present in the Program.cs generated by the template for apps, - // which is the following: - - // 0. WebApplication.CreateBuilder() - builder ??= WebApplication.CreateBuilder(); - builder.Environment.EnvironmentName = "Development"; - - if (configData is not null) - { - builder.Configuration.AddInMemoryCollection(configData); - } - - // 1. AddAltinnAppControllersWithViews - Altinn.App.Api.Extensions.ServiceCollectionExtensions.AddAltinnAppControllersWithViews(builder.Services); - - // 2. RegisterCustomAppServices - registerCustomAppServices?.Invoke(builder.Services); - - // 3. AddAltinnAppServices - Altinn.App.Api.Extensions.ServiceCollectionExtensions.AddAltinnAppServices( - builder.Services, - builder.Configuration, - builder.Environment - ); - - // 4. ConfigureAppWebHost - Altinn.App.Api.Extensions.WebHostBuilderExtensions.ConfigureAppWebHost(builder.WebHost, []); - - // 5. UseAltinnAppCommonConfiguration - var app = builder.Build(); - Altinn.App.Api.Extensions.WebApplicationBuilderExtensions.UseAltinnAppCommonConfiguration(app); - - return app; - } -} diff --git a/test/Altinn.App.Core.Tests/Altinn.App.Core.Tests.csproj b/test/Altinn.App.Core.Tests/Altinn.App.Core.Tests.csproj index 7d3223609..b7775ad29 100644 --- a/test/Altinn.App.Core.Tests/Altinn.App.Core.Tests.csproj +++ b/test/Altinn.App.Core.Tests/Altinn.App.Core.Tests.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -42,7 +42,6 @@ - @@ -62,7 +61,6 @@ - @@ -103,4 +101,4 @@ - + \ No newline at end of file diff --git a/test/Altinn.App.Core.Tests/Features/Maskinporten/Delegates/MaskinportenDelegatingHandlerTest.cs b/test/Altinn.App.Core.Tests/Features/Maskinporten/Delegates/MaskinportenDelegatingHandlerTest.cs deleted file mode 100644 index b98ad8f28..000000000 --- a/test/Altinn.App.Core.Tests/Features/Maskinporten/Delegates/MaskinportenDelegatingHandlerTest.cs +++ /dev/null @@ -1,63 +0,0 @@ -using Altinn.App.Core.Features.Maskinporten.Exceptions; -using Altinn.App.Core.Features.Maskinporten.Models; -using FluentAssertions; -using Moq; - -namespace Altinn.App.Core.Tests.Features.Maskinporten.Tests.Delegates; - -public class MaskinportenDelegatingHandlerTest -{ - [Fact] - public async Task SendAsync_AddsAuthorizationHeader() - { - // Arrange - var scopes = new[] { "scope1", "scope2" }; - var (client, handler) = TestHelpers.MockMaskinportenDelegatingHandlerFactory( - scopes, - new MaskinportenTokenResponse - { - TokenType = "Bearer", - Scope = "-", - AccessToken = "jwt-content-placeholder", - ExpiresIn = -1 - } - ); - var httpClient = new HttpClient(handler); - var request = new HttpRequestMessage(HttpMethod.Get, "https://unittesting.to.nowhere"); - - // Act - await httpClient.SendAsync(request); - - // Assert - client.Verify(c => c.GetAccessToken(scopes, It.IsAny()), Times.Once); - Assert.NotNull(request.Headers.Authorization); - request.Headers.Authorization.Scheme.Should().Be("Bearer"); - request.Headers.Authorization.Parameter.Should().Be("jwt-content-placeholder"); - } - - [Fact] - public async Task SendAsync_OnlyAccepts_BearerTokens() - { - // Arrange - var (_, handler) = TestHelpers.MockMaskinportenDelegatingHandlerFactory( - ["scope1", "scope2"], - new MaskinportenTokenResponse - { - TokenType = "MAC", - Scope = "-", - AccessToken = "jwt-content-placeholder", - ExpiresIn = -1 - } - ); - var httpClient = new HttpClient(handler); - var request = new HttpRequestMessage(HttpMethod.Get, "https://unittesting.to.nowhere"); - - // Act - Func act = async () => await httpClient.SendAsync(request); - - // Assert - await act.Should() - .ThrowAsync() - .WithMessage("Unsupported token type received from Maskinporten: *"); - } -} diff --git a/test/Altinn.App.Core.Tests/Features/Maskinporten/MaskinportenClientTest.cs b/test/Altinn.App.Core.Tests/Features/Maskinporten/MaskinportenClientTest.cs deleted file mode 100644 index 765d562d4..000000000 --- a/test/Altinn.App.Core.Tests/Features/Maskinporten/MaskinportenClientTest.cs +++ /dev/null @@ -1,293 +0,0 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Net; -using System.Text.Json; -using Altinn.App.Core.Features.Maskinporten; -using Altinn.App.Core.Features.Maskinporten.Exceptions; -using Altinn.App.Core.Features.Maskinporten.Models; -using FluentAssertions; -using Microsoft.Extensions.Caching.Hybrid; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Internal; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Microsoft.Extensions.Time.Testing; -using Moq; - -namespace Altinn.App.Core.Tests.Features.Maskinporten; - -public class MaskinportenClientTests -{ - private sealed class FakeTime(DateTimeOffset startDateTime) : FakeTimeProvider(startDateTime), ISystemClock - { - public DateTimeOffset UtcNow => GetUtcNow(); - } - - private readonly Mock _mockHttpClientFactory; - private readonly FakeTime _fakeTimeProvider; - private readonly MaskinportenClient _maskinportenClient; - private readonly MaskinportenSettings _maskinportenSettings = - new() - { - Authority = "https://maskinporten.dev/", - ClientId = "test-client-id", - JwkBase64 = - "ewogICAgICAicCI6ICItU09GNmp3V0N3b19nSlByTnJhcVNkNnZRckFzRmxZd1VScHQ0NC1BNlRXUnBoaUo4b3czSTNDWGxxUG1LeG5VWDVDcnd6SF8yeldTNGtaaU9zQTMtajhiUE9hUjZ2a3pRSG14YmFkWmFmZjBUckdJajNQUlhxcVdMRHdsZjNfNklDV2gzOFhodXNBeDVZRE0tRm8zZzRLVWVHM2NxMUFvTkJ4NHV6Sy1IRHMiLAogICAgICAia3R5IjogIlJTQSIsCiAgICAgICJxIjogIndwWUlpOVZJLUJaRk9aYUNaUmVhYm4xWElQbW8tbEJIendnc1RCdHVfeUJma1FQeGI1Q1ZnZFFnaVQ4dTR3Tkl4NC0zb2ROdXhsWGZING1Hc25xOWFRaFlRNFEyc2NPUHc5V2dNM1dBNE1GMXNQQXgzUGJLRkItU01RZmZ4aXk2cVdJSmRQSUJ4OVdFdnlseW9XbEhDcGZsUWplT3U2dk43WExsZ3c5T2JhVSIsCiAgICAgICJkIjogIks3Y3pqRktyWUJfRjJYRWdoQ1RQY2JTbzZZdExxelFwTlZleF9HZUhpTmprWmNpcEVaZ3g4SFhYLXpNSi01ZWVjaTZhY1ZjSzhhZzVhQy01Mk84LTU5aEU3SEE2M0FoRzJkWFdmamdQTXhaVE9MbnBheWtZbzNWa0NGNF9FekpLYmw0d2ludnRuTjBPc2dXaVZiTDFNZlBjWEdqbHNTUFBIUlAyaThDajRqX21OM2JVcy1FbVM5UzktSXlia1luYV9oNUMxMEluXy1tWHpsQ2dCNU9FTXFzd2tNUWRZVTBWbHVuWHM3YXlPT0h2WWpQMWFpYml0MEpyay1iWVFHSy1mUVFFVWNZRkFSN1ZLMkxIaUJwU0NvbzBiSjlCQ1BZb196bTVNVnVId21xbzNtdml1Vy1lMnVhbW5xVHpZUEVWRE1lMGZBSkZtcVBGcGVwTzVfcXE2USIsCiAgICAgICJlIjogIkFRQUIiLAogICAgICAidXNlIjogInNpZyIsCiAgICAgICJraWQiOiAiYXNkZjEyMzQiLAogICAgICAicWkiOiAicXpFUUdXOHBPVUgtR2pCaFUwVXNhWWtEM2dWTVJvTF9CbGlRckp4ZTAwY29YeUtIZGVEX2M1bDFDNFFJZzRJSjZPMnFZZ2wyamRnWVNmVHA0S2NDNk1Obm8tSVFiSnlPRDU2Qmo4eVJUUjA5TkZvTGhDUjNhY0xmMkhwTXNKNUlqbTdBUHFPVWlCeW9hVkExRlR4bzYtZGNfZ1NiQjh1ZDI2bFlFRHdsYWMwIiwKICAgICAgImRwIjogInRnTU14N2FFQ0NiQmctY005Vmo0Q2FXbGR0d01LWGxvTFNoWTFlSTJOS3BOTVFKR2JhdWdjTVRHQ21qTk1fblgzTVZ0cHRvMWFPbTMySlhCRjlqc1RHZWtONWJmVGNJbmZsZ3Bsc21uR2pMckNqN0xYTG9wWUxiUnBabF9iNm1JaThuU2ZCQXVQR2hEUzc4UWZfUXhFR1Bxb2h6cEZVTW5UQUxzOVI0Nkk1YyIsCiAgICAgICJhbGciOiAiUlMyNTYiLAogICAgICAiZHEiOiAibE40cF9ha1lZVXpRZTBWdHp4LW1zNTlLLUZ4bzdkQmJqOFhGOWhnSzdENzlQam5SRGJTRTNVWEgtcGlQSzNpSXhyeHFGZkZuVDJfRS15REJIMjBOMmZ4YllwUVZNQnpZc1UtUGQ2OFBBV1Nnd05TU29XVmhwdEdjaTh4bFlfMDJkWDRlbEF6T1ZlOUIxdXBEMjc5cWJXMVdKVG5TQmp4am1LVU5lQjVPdDAwIiwKICAgICAgIm4iOiAidlY3dW5TclNnekV3ZHo0dk8wTnNmWDB0R1NwT2RITE16aDFseUVtU2RYbExmeVYtcUxtbW9qUFI3S2pUU2NDbDI1SFI4SThvWG1mcDhSZ19vbnA0LUlZWW5ZV0RTNngxVlViOVlOQ3lFRTNQQTUtVjlOYzd5ckxxWXpyMTlOSkJmdmhJVEd5QUFVTjFCeW5JeXJ5NFFMbHRYYTRKSTFiLTh2QXNJQ0xyU1dQZDdibWxrOWo3bU1jV3JiWlNIZHNTMGNpVFgzYTc2UXdMb0F2SW54RlhCU0ludXF3ZVhnVjNCZDFQaS1DZGpCR0lVdXVyeVkybEwybmRnVHZUY2tZUTBYeEtGR3lCdDNaMEhJMzRBRFBrVEZneWFMX1F4NFpIZ3d6ZjRhTHBXaHF3OGVWanpPMXlucjJ3OUd4b2dSN1pWUjY3VFI3eUxSS3VrMWdIdFlkUkJ3IgogICAgfQ==" - }; - - public MaskinportenClientTests() - { - _mockHttpClientFactory = new Mock(); - _fakeTimeProvider = new FakeTime(DateTimeOffset.UtcNow); - - var app = Api.Tests.TestUtils.AppBuilder.Build(registerCustomAppServices: services => - services.Configure(options => options.Clock = _fakeTimeProvider) - ); - - var tokenCache = app.Services.GetRequiredService(); - var mockLogger = new Mock>(); - var mockOptions = new Mock>(); - mockOptions.Setup(o => o.CurrentValue).Returns(_maskinportenSettings); - - _maskinportenClient = new MaskinportenClient( - mockOptions.Object, - _mockHttpClientFactory.Object, - tokenCache, - mockLogger.Object, - _fakeTimeProvider - ); - } - - [Fact] - public void FormattedScopes_FormatsCorrectly() - { - MaskinportenClient.FormattedScopes(["a", "b", "c"]).Should().Be("a b c"); - MaskinportenClient.FormattedScopes(["a b", "c"]).Should().Be("a b c"); - MaskinportenClient.FormattedScopes(["a b c"]).Should().Be("a b c"); - } - - [Fact] - public async Task GenerateAuthenticationPayload_HasCorrectFormat() - { - // Arrange - var jwt = "access-token-content"; - - // Act - var content = MaskinportenClient.GenerateAuthenticationPayload(jwt); - var parsed = await TestHelpers.ParseFormUrlEncodedContent(content); - - // Assert - parsed.Count.Should().Be(2); - parsed["grant_type"].Should().Be("urn:ietf:params:oauth:grant-type:jwt-bearer"); - parsed["assertion"].Should().Be(jwt); - } - - [Fact] - public void GenerateJwtGrant_HasCorrectFormat() - { - // Arrange - var scopes = "scope1 scope2"; - - // Act - var jwt = _maskinportenClient.GenerateJwtGrant(scopes); - var parsed = new JwtSecurityTokenHandler().ReadJwtToken(jwt); - - // Assert - parsed.Audiences.Count().Should().Be(1); - parsed.Audiences.First().Should().Be(_maskinportenSettings.Authority); - parsed.Issuer.Should().Be(_maskinportenSettings.ClientId); - parsed.Claims.First(x => x.Type == "scope").Value.Should().Be(scopes); - } - - [Fact] - public async Task GetAccessToken_ReturnsAToken() - { - // Arrange - string[] scopes = ["scope1", "scope2"]; - var tokenResponse = new MaskinportenTokenResponse - { - AccessToken = "access-token-content", - ExpiresIn = 120, - Scope = MaskinportenClient.FormattedScopes(scopes), - TokenType = "Bearer" - }; - _mockHttpClientFactory - .Setup(x => x.CreateClient(It.IsAny())) - .Returns(() => - { - var mockHandler = TestHelpers.MockHttpMessageHandlerFactory(tokenResponse); - return new HttpClient(mockHandler.Object); - }); - - // Act - var result = await _maskinportenClient.GetAccessToken(scopes); - - // Assert - result.Should().BeEquivalentTo(tokenResponse, config => config.Excluding(x => x.ExpiresAt)); - } - - [Fact] - public async Task GetAccessToken_ThrowsExceptionWhenTokenIsExpired() - { - // Arrange - var scopes = new List { "scope1", "scope2" }; - var tokenResponse = new MaskinportenTokenResponse - { - AccessToken = "expired-access-token", - ExpiresIn = MaskinportenClient.TokenExpirationMargin - 1, - Scope = "-", - TokenType = "Bearer" - }; - - _mockHttpClientFactory - .Setup(x => x.CreateClient(It.IsAny())) - .Returns(() => - { - var mockHandler = TestHelpers.MockHttpMessageHandlerFactory(tokenResponse); - return new HttpClient(mockHandler.Object); - }); - - // Act - Func act = async () => - { - await _maskinportenClient.GetAccessToken(scopes); - }; - - // Assert - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task GetAccessToken_UsesCachedTokenIfAvailable() - { - // Arrange - string[] scopes = ["scope1", "scope2"]; - var tokenResponse = new MaskinportenTokenResponse - { - AccessToken = "2 minute access token content", - ExpiresIn = 120, - Scope = MaskinportenClient.FormattedScopes(scopes), - TokenType = "Bearer" - }; - _mockHttpClientFactory - .Setup(x => x.CreateClient(It.IsAny())) - .Returns(() => - { - var mockHandler = TestHelpers.MockHttpMessageHandlerFactory(tokenResponse); - return new HttpClient(mockHandler.Object); - }); - - // Act - var token1 = await _maskinportenClient.GetAccessToken(scopes); - _fakeTimeProvider.Advance(TimeSpan.FromMinutes(1)); - var token2 = await _maskinportenClient.GetAccessToken(scopes); - - // Assert - token1.Should().BeSameAs(token2); - } - - [Fact] - public async Task GetAccessToken_GeneratesNewTokenIfRequired() - { - // Arrange - string[] scopes = ["scope1", "scope2"]; - var tokenResponse = new MaskinportenTokenResponse - { - AccessToken = "Very short lived access token", - ExpiresIn = MaskinportenClient.TokenExpirationMargin + 1, - Scope = MaskinportenClient.FormattedScopes(scopes), - TokenType = "Bearer" - }; - _mockHttpClientFactory - .Setup(x => x.CreateClient(It.IsAny())) - .Returns(() => - { - var mockHandler = TestHelpers.MockHttpMessageHandlerFactory(tokenResponse); - return new HttpClient(mockHandler.Object); - }); - - // Act - var token1 = await _maskinportenClient.GetAccessToken(scopes); - _fakeTimeProvider.Advance(TimeSpan.FromSeconds(10)); - var token2 = await _maskinportenClient.GetAccessToken(scopes); - - // Assert - token1.Should().NotBeSameAs(token2); - } - - [Fact] - public async Task ParseServerResponse_ThrowsOn_UnsuccessfulStatusCode() - { - // Arrange - var unauthorizedResponse = new HttpResponseMessage - { - StatusCode = HttpStatusCode.Unauthorized, - Content = new StringContent(string.Empty) - }; - - // Act - Func act = async () => - { - await MaskinportenClient.ParseServerResponse(unauthorizedResponse); - }; - - // Assert - await act.Should() - .ThrowAsync() - .WithMessage( - $"Maskinporten authentication failed with status code {(int)unauthorizedResponse.StatusCode} *" - ); - } - - [Fact] - public async Task ParseServerResponse_ThrowsOn_InvalidJson() - { - // Arrange - var invalidJsonResponse = new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = new StringContent("Bad json formatting") - }; - - // Act - Func act = async () => - { - await MaskinportenClient.ParseServerResponse(invalidJsonResponse); - }; - - // Assert - await act.Should() - .ThrowAsync() - .WithMessage("Maskinporten replied with invalid JSON formatting: *"); - } - - [Fact] - public async Task ParseServerResponse_ThrowsOn_DisposedObject() - { - // Arrange - var tokenResponse = new MaskinportenTokenResponse - { - AccessToken = "access-token-content", - ExpiresIn = 120, - Scope = "scope1 scope2", - TokenType = "Bearer" - }; - var validHttpResponse = new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = new StringContent(JsonSerializer.Serialize(tokenResponse)) - }; - - // Act - validHttpResponse.Dispose(); - Func act = async () => - { - await MaskinportenClient.ParseServerResponse(validHttpResponse); - }; - - // Assert - await act.Should() - .ThrowAsync() - .WithMessage("Authentication with Maskinporten failed: *"); - } -} diff --git a/test/Altinn.App.Core.Tests/Features/Maskinporten/Models/MaskinportenSettingsTest.cs b/test/Altinn.App.Core.Tests/Features/Maskinporten/Models/MaskinportenSettingsTest.cs deleted file mode 100644 index 717c9660a..000000000 --- a/test/Altinn.App.Core.Tests/Features/Maskinporten/Models/MaskinportenSettingsTest.cs +++ /dev/null @@ -1,237 +0,0 @@ -using System.Text; -using System.Text.Json; -using Altinn.App.Core.Features.Maskinporten.Exceptions; -using Altinn.App.Core.Features.Maskinporten.Models; -using FluentAssertions; -using Microsoft.IdentityModel.Tokens; - -namespace Altinn.App.Core.Tests.Features.Maskinporten.Models; - -public class MaskinportenSettingsTest -{ - /// - /// This key definition is complete and valid - /// - private string validJwk = """ - { - "p": "5BRHaF0zpryULcbyTf02xZUXMb26Ait8XvU4NsAYCH4iLNkC_zYRJ0X_qb0sJ_WVYecB1-nCV1Qr15KnsaKp1qBOx21_ftHHwdBE12z9KYGe1xQ4ZIXEP0OiR044XQPphRFVjWOF7wQKdoXlTNXCg4B3lo5waBj8eYmMHCxyK6k", - "kty": "RSA", - "q": "yR6wLPzQ35bc0ZIxuzuil9sRpMZhqk2tWe6cV1YkqxSXPDLOjHBPbwdeNdd2qdSY_x0myzIF_KA0xD-Q5YXCMt-8UxJTYKf8TLIxTdRKyW7KQbR0HJ4yNx0DuoXEeeDfLXbMX_TbL_W6N4xHPHiuGvh1Spr4s4JC0Ka1PLK8b2E", - "d": "jR4l-ZW3_eAqTRxmmkYNTGxp8fURn8C9mar5-NatcyR5HfqpofjQIVtGLNfhS-YMkeam8pIXjsdrWrSTIC22uUf4OuNDRWsKEwePYoNO1xNusF-8fOMM7At6qtPpcXk3pEHfEuSjplIOAL9scj2oeF3jqe5eP9l4KHDYLugqkxJz3AoObTBQDykXx3uq_3cjeSBss1XFdEnpD2Br1zR7-sGaEoSIQyT6a8Ulgr1Ah5AHm6KX4LTgPx3NuWLyDqN-L6QxYnv27BC6J-4ehwpf84CO-uolJKcVPvEwBf35LFlBA3JgKaVYyC7SZ5A2y_EBViKhubgOuMm9_2C7o9PyAQ", - "e": "AQAB", - "use": "sig", - "kid": "test-kid", - "qi": "E-oyO4HWOVxD_d7xZxFltTZDz6ZtLPZFB_KYXYeVFDrO8KZE9kFb4TNlFvrFjv-dHtpNey95gqtOtdNMwdVdZbAKbDdo5LYSJ_rk-4ZVsDusq7FCJ5nmmrxfQ5yNEPqHwgdUfs50v_fV1x8SEDjfWzaaVK5ltqPiUXtpTTLBQIg", - "dp": "yVvJ6y6VgjfszjldBFNv_qHwlz58MJw5sg_mcBfJX_4Tp-pzReNy42xeGXnkuOaM2qE6tGcw5y5tgmV8XUxRiyV-R3y5WbpVFBwOGu6i1vkTxaiZXM3oAz5vz2oUQrJIgO1bzXa28NxtbFQrq1jw4G4Tpjzcqlqc06QGqXzn0vk", - "alg": "RS256", - "dq": "B5CI7dhAfvhsq9FE35b5oZ6SxlDT4ZT0XTqVVM-fp3Op0JDUpgGfazyqtXm6M98UNhxBlkj2Yq8f7PW7HHbwe_tgWPuKeUs4OSZGpnfCrFrnbps79suYdew4dK6NWkwz-MDMJRvPlrk2XNqA32xmmAsaVkkH67CNlM2AaZ0La2E", - "n": "sy9DZ1U6jfP1UBN2EZTD1DPkajdZsFsXGGVHfbJmH5MFwXbtKlwV_jYjz58YIj1n48OxH7f-Ldgc-fBLz45QU1HbDZPij7q3uYm1ZMTGkPqkY8kHX51TsFOEqzVhNfyc6yVsjlj5KPyyxLyAcx6ixiE2K8vIeuKMVbZCZt605L39ENUsiQ-cfnqp-zo1ihU5xJOQaWV9pGuG4XoLAUIktF6_YPF4pFmSWRHk5aURUfTCvo11n3EUBYJUiJb8AqUt3yqGSoV-4wXir-9oRNjDUtE_QA3eErGKCebtUd6oxzcXcHiGY0npKxt7JQti3jTZRcnkScmmP-XvrQzB6kzSCQ" - } - """; - - /// - /// This is a Base64 encoded version of . - /// - /// - private string validJwk_Base64 => Convert.ToBase64String(Encoding.UTF8.GetBytes(validJwk)); - - /// - /// This key definition is missing the `e` exponent and the `kid` identifier - /// - private string invalidJwk = """ - { - "p": "5BRHaF0zpryULcbyTf02xZUXMb26Ait8XvU4NsAYCH4iLNkC_zYRJ0X_qb0sJ_WVYecB1-nCV1Qr15KnsaKp1qBOx21_ftHHwdBE12z9KYGe1xQ4ZIXEP0OiR044XQPphRFVjWOF7wQKdoXlTNXCg4B3lo5waBj8eYmMHCxyK6k", - "kty": "RSA", - "q": "yR6wLPzQ35bc0ZIxuzuil9sRpMZhqk2tWe6cV1YkqxSXPDLOjHBPbwdeNdd2qdSY_x0myzIF_KA0xD-Q5YXCMt-8UxJTYKf8TLIxTdRKyW7KQbR0HJ4yNx0DuoXEeeDfLXbMX_TbL_W6N4xHPHiuGvh1Spr4s4JC0Ka1PLK8b2E", - "d": "jR4l-ZW3_eAqTRxmmkYNTGxp8fURn8C9mar5-NatcyR5HfqpofjQIVtGLNfhS-YMkeam8pIXjsdrWrSTIC22uUf4OuNDRWsKEwePYoNO1xNusF-8fOMM7At6qtPpcXk3pEHfEuSjplIOAL9scj2oeF3jqe5eP9l4KHDYLugqkxJz3AoObTBQDykXx3uq_3cjeSBss1XFdEnpD2Br1zR7-sGaEoSIQyT6a8Ulgr1Ah5AHm6KX4LTgPx3NuWLyDqN-L6QxYnv27BC6J-4ehwpf84CO-uolJKcVPvEwBf35LFlBA3JgKaVYyC7SZ5A2y_EBViKhubgOuMm9_2C7o9PyAQ", - "e": "", - "use": "sig", - "kid": "", - "qi": "E-oyO4HWOVxD_d7xZxFltTZDz6ZtLPZFB_KYXYeVFDrO8KZE9kFb4TNlFvrFjv-dHtpNey95gqtOtdNMwdVdZbAKbDdo5LYSJ_rk-4ZVsDusq7FCJ5nmmrxfQ5yNEPqHwgdUfs50v_fV1x8SEDjfWzaaVK5ltqPiUXtpTTLBQIg", - "dp": "yVvJ6y6VgjfszjldBFNv_qHwlz58MJw5sg_mcBfJX_4Tp-pzReNy42xeGXnkuOaM2qE6tGcw5y5tgmV8XUxRiyV-R3y5WbpVFBwOGu6i1vkTxaiZXM3oAz5vz2oUQrJIgO1bzXa28NxtbFQrq1jw4G4Tpjzcqlqc06QGqXzn0vk", - "alg": "RS256", - "dq": "B5CI7dhAfvhsq9FE35b5oZ6SxlDT4ZT0XTqVVM-fp3Op0JDUpgGfazyqtXm6M98UNhxBlkj2Yq8f7PW7HHbwe_tgWPuKeUs4OSZGpnfCrFrnbps79suYdew4dK6NWkwz-MDMJRvPlrk2XNqA32xmmAsaVkkH67CNlM2AaZ0La2E", - "n": "sy9DZ1U6jfP1UBN2EZTD1DPkajdZsFsXGGVHfbJmH5MFwXbtKlwV_jYjz58YIj1n48OxH7f-Ldgc-fBLz45QU1HbDZPij7q3uYm1ZMTGkPqkY8kHX51TsFOEqzVhNfyc6yVsjlj5KPyyxLyAcx6ixiE2K8vIeuKMVbZCZt605L39ENUsiQ-cfnqp-zo1ihU5xJOQaWV9pGuG4XoLAUIktF6_YPF4pFmSWRHk5aURUfTCvo11n3EUBYJUiJb8AqUt3yqGSoV-4wXir-9oRNjDUtE_QA3eErGKCebtUd6oxzcXcHiGY0npKxt7JQti3jTZRcnkScmmP-XvrQzB6kzSCQ" - } - """; - - /// - /// This is a Base64 encoded version of . - /// - /// - private string invalidJwk_base64 => Convert.ToBase64String(Encoding.UTF8.GetBytes(invalidJwk)); - - [Fact] - public void ShouldDeserializeFromJsonCorrectly() - { - // Arrange - var json = $$""" - { - "authority": "https://maskinporten.dev/", - "clientId": "test-client", - "jwk": {{validJwk}} - } - """; - - // Act - var settings = JsonSerializer.Deserialize(json); - - // Assert - Assert.NotNull(settings); - settings.Authority.Should().Be("https://maskinporten.dev/"); - settings.GetJsonWebKey().KeyId.Should().Be("test-kid"); - settings.ClientId.Should().Be("test-client"); - } - - [Fact] - public void ShouldDeserializeFromJsonCorrectly_Base64Encoded() - { - // Arrange - var json = $$""" - { - "authority": "https://maskinporten.dev/", - "clientId": "test-client", - "jwkBase64": "{{validJwk_Base64}}" - } - """; - - // Act - var settings = JsonSerializer.Deserialize(json); - - // Assert - Assert.NotNull(settings); - settings.Authority.Should().Be("https://maskinporten.dev/"); - settings.ClientId.Should().Be("test-client"); - settings.GetJsonWebKey().KeyId.Should().Be("test-kid"); - } - - [Fact] - public void ShouldValidateJwkAfterDeserializing() - { - // Arrange - var json = $$""" - { - "authority": "https://maskinporten.dev/", - "clientId": "test-client", - "jwk": {{invalidJwk}} - } - """; - - // Act - var settings = JsonSerializer.Deserialize(json); - Func act = () => - { - Assert.NotNull(settings); - return settings.GetJsonWebKey(); - }; - - // Assert - act.Should() - .Throw() - .WithMessage("The * is invalid after deserialization, not all required properties were found: *"); - } - - [Fact] - public void ShouldValidateJwkAfterDeserializing_Base64() - { - // Arrange - var json = $$""" - { - "authority": "https://maskinporten.dev/", - "clientId": "test-client", - "jwkBase64": "{{invalidJwk_base64}}" - } - """; - - // Act - var settings = JsonSerializer.Deserialize(json); - Func act = () => - { - Assert.NotNull(settings); - return settings.GetJsonWebKey(); - }; - - // Assert - act.Should() - .Throw() - .WithMessage("The * is invalid after deserialization, not all required properties were found: *"); - } - - [Fact] - public void ShouldThrowOnBadBas64String1() - { - // Arrange - // `jwkBase64` is *not* base64 encoded - var json = $$""" - { - "authority": "https://maskinporten.dev/", - "clientId": "test-client", - "jwkBase64": "this is not the right stuff" - } - """; - - // Act - var settings = JsonSerializer.Deserialize(json); - Func act = () => - { - Assert.NotNull(settings); - return settings.GetJsonWebKey(); - }; - - // Assert - act.Should() - .Throw() - .WithMessage("Error decoding MaskinportenSettings.JwkBase64 from base64: *"); - } - - [Fact] - public void ShouldThrowOnBadBas64String2() - { - // Arrange - // `jwkBase64` is base64 encoded, but contains invalid data - var json = $$""" - { - "authority": "https://maskinporten.dev/", - "clientId": "test-client", - "jwkBase64": "dGhpcyBpcyBhbiBpbnZhbGlkIEp3a1dyYXBwZXIgb2JqZWN0" - } - """; - - // Act - var settings = JsonSerializer.Deserialize(json); - Func act = () => - { - Assert.NotNull(settings); - return settings.GetJsonWebKey(); - }; - - // Assert - act.Should() - .Throw() - .WithMessage("Error parsing MaskinportenSettings.JwkBase64 JSON structure: *"); - } - - [Fact] - public void RequiresAtLeastOneJwk() - { - // Arrange - // `jwk` and `jwkBase64` is missing - var json = $$""" - { - "authority": "https://maskinporten.dev/", - "clientId": "test-client" - } - """; - - // Act - var settings = JsonSerializer.Deserialize(json); - Func act = () => - { - Assert.NotNull(settings); - return settings.GetJsonWebKey(); - }; - - // Assert - act.Should().Throw().WithMessage("No private key configured*"); - } -} diff --git a/test/Altinn.App.Core.Tests/Features/Maskinporten/Models/MaskinportenTokenResponseTest.cs b/test/Altinn.App.Core.Tests/Features/Maskinporten/Models/MaskinportenTokenResponseTest.cs deleted file mode 100644 index 0c49cf5c0..000000000 --- a/test/Altinn.App.Core.Tests/Features/Maskinporten/Models/MaskinportenTokenResponseTest.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Text.Json; -using Altinn.App.Core.Features.Maskinporten.Models; -using FluentAssertions; - -namespace Altinn.App.Core.Tests.Features.Maskinporten.Models; - -public class MaskinportenTokenResponseTest -{ - [Fact] - public void ShouldDeserializeFromJsonCorrectly() - { - // Arrange - var json = """ - { - "access_token": "jwt.content.here", - "token_type": "Bearer", - "expires_in": 120, - "scope": "anything" - } - """; - - // Act - var beforeCreation = DateTime.UtcNow; - var token = JsonSerializer.Deserialize(json); - var afterCreation = DateTime.UtcNow; - - // Assert - Assert.NotNull(token); - token.AccessToken.Should().Be("jwt.content.here"); - token.TokenType.Should().Be("Bearer"); - token.Scope.Should().Be("anything"); - token.ExpiresIn.Should().Be(120); - token.ExpiresAt.Should().BeBefore(afterCreation.AddSeconds(120)).And.BeAfter(beforeCreation.AddSeconds(120)); - } -} diff --git a/test/Altinn.App.Core.Tests/Features/Maskinporten/TestHelpers.cs b/test/Altinn.App.Core.Tests/Features/Maskinporten/TestHelpers.cs deleted file mode 100644 index f1cee45c8..000000000 --- a/test/Altinn.App.Core.Tests/Features/Maskinporten/TestHelpers.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System.Net; -using System.Security.Cryptography; -using System.Text.Json; -using Altinn.App.Core.Features.Maskinporten; -using Altinn.App.Core.Features.Maskinporten.Delegates; -using Altinn.App.Core.Features.Maskinporten.Models; -using Microsoft.Extensions.Logging; -using Microsoft.IdentityModel.Tokens; -using Moq; -using Moq.Protected; - -namespace Altinn.App.Core.Tests.Features.Maskinporten; - -internal static class TestHelpers -{ - public static Mock MockHttpMessageHandlerFactory(MaskinportenTokenResponse tokenResponse) - { - var handlerMock = new Mock(); - handlerMock - .Protected() - .Setup>( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny() - ) - .ReturnsAsync( - () => - new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = new StringContent(JsonSerializer.Serialize(tokenResponse)) - } - ); - - return handlerMock; - } - - public static ( - Mock client, - MaskinportenDelegatingHandler handler - ) MockMaskinportenDelegatingHandlerFactory(IEnumerable scopes, MaskinportenTokenResponse tokenResponse) - { - var mockProvider = new Mock(); - var innerHandlerMock = new Mock(); - var mockLogger = new Mock>(); - var mockMaskinportenClient = new Mock(); - - mockProvider - .Setup(p => p.GetService(typeof(ILogger))) - .Returns(mockLogger.Object); - mockProvider.Setup(p => p.GetService(typeof(IMaskinportenClient))).Returns(mockMaskinportenClient.Object); - - innerHandlerMock - .Protected() - .Setup>( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny() - ) - .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); - - mockMaskinportenClient - .Setup(c => c.GetAccessToken(scopes, It.IsAny())) - .ReturnsAsync(tokenResponse); - - var handler = new MaskinportenDelegatingHandler(scopes, mockMaskinportenClient.Object, mockLogger.Object) - { - InnerHandler = innerHandlerMock.Object - }; - - return (mockMaskinportenClient, handler); - } - - public static async Task> ParseFormUrlEncodedContent(FormUrlEncodedContent formData) - { - var content = await formData.ReadAsStringAsync(); - return content - .Split('&') - .Select(pair => pair.Split('=')) - .ToDictionary(split => Uri.UnescapeDataString(split[0]), split => Uri.UnescapeDataString(split[1])); - } -}