Skip to content

Commit

Permalink
Correspondence client (#897)
Browse files Browse the repository at this point in the history
  • Loading branch information
danielskovli authored Nov 21, 2024
1 parent eead33f commit 9cb9bc6
Show file tree
Hide file tree
Showing 93 changed files with 7,265 additions and 446 deletions.
40 changes: 31 additions & 9 deletions src/Altinn.App.Api/Extensions/HttpClientBuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Altinn.App.Core.Features.Maskinporten;
using Altinn.App.Core.Features.Maskinporten.Delegates;
using Altinn.App.Core.Features.Maskinporten.Constants;
using Altinn.App.Core.Features.Maskinporten.Extensions;

namespace Altinn.App.Api.Extensions;

Expand All @@ -10,25 +11,46 @@ public static class HttpClientBuilderExtensions
{
/// <summary>
/// <para>
/// Sets up a <see cref="MaskinportenDelegatingHandler"/> middleware for the supplied <see cref="HttpClient"/>,
/// which will inject an Authorization header with a Bearer token for all requests.
/// Authorises all requests with Maskinporten using the provided scopes,
/// and injects the resulting token in the Authorization header using the Bearer scheme.
/// </para>
/// <para>
/// If your target API does <em>not</em> use this authentication scheme, you should consider implementing
/// <see cref="MaskinportenClient.GetAccessToken"/> directly and handling authorization details manually.
/// If your target API does <em>not</em> use this authorisation scheme, you should consider implementing
/// <see cref="MaskinportenClient.GetAccessToken"/> directly and handling the specifics manually.
/// </para>
/// </summary>
/// <param name="builder">The Http client builder</param>
/// <param name="scope">The scope to claim authorization for with Maskinporten</param>
/// <param name="additionalScopes">Additional scopes as required</param>
public static IHttpClientBuilder UseMaskinportenAuthorization(
public static IHttpClientBuilder UseMaskinportenAuthorisation(
this IHttpClientBuilder builder,
string scope,
params string[] additionalScopes
)
{
var scopes = new[] { scope }.Concat(additionalScopes);
var factory = ActivatorUtilities.CreateFactory<MaskinportenDelegatingHandler>([typeof(IEnumerable<string>)]);
return builder.AddHttpMessageHandler(provider => factory(provider, [scopes]));
return builder.AddMaskinportenHttpMessageHandler(scope, additionalScopes, TokenAuthorities.Maskinporten);
}

/// <summary>
/// <para>
/// Authorises all requests with Maskinporten using the provided scopes.
/// The resulting token is then exchanged for an Altinn issued token and injected in
/// the Authorization header using the Bearer scheme.
/// </para>
/// <para>
/// If your target API does <em>not</em> use this authorisation scheme, you should consider implementing
/// <see cref="MaskinportenClient.GetAltinnExchangedToken(IEnumerable{string}, CancellationToken)"/> directly and handling the specifics manually.
/// </para>
/// </summary>
/// <param name="builder">The Http client builder</param>
/// <param name="scope">The scope to claim authorization for with Maskinporten</param>
/// <param name="additionalScopes">Additional scopes as required</param>
public static IHttpClientBuilder UseMaskinportenAltinnAuthorisation(
this IHttpClientBuilder builder,
string scope,
params string[] additionalScopes
)
{
return builder.AddMaskinportenHttpMessageHandler(scope, additionalScopes, TokenAuthorities.AltinnTokenExchange);
}
}
38 changes: 7 additions & 31 deletions src/Altinn.App.Api/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
using Altinn.App.Core.Constants;
using Altinn.App.Core.Extensions;
using Altinn.App.Core.Features;
using Altinn.App.Core.Features.Correspondence.Extensions;
using Altinn.App.Core.Features.Maskinporten;
using Altinn.App.Core.Features.Maskinporten.Extensions;
using Altinn.App.Core.Features.Maskinporten.Models;
using Altinn.Common.PEP.Authorization;
using Altinn.Common.PEP.Clients;
Expand Down Expand Up @@ -82,7 +84,6 @@ IWebHostEnvironment env

services.AddPlatformServices(config, env);
services.AddAppServices(config, env);
services.AddMaskinportenClient();
services.ConfigureDataProtection();

var useOpenTelemetrySetting = config.GetValue<bool?>("AppSettings:UseOpenTelemetry");
Expand All @@ -97,6 +98,11 @@ IWebHostEnvironment env
AddApplicationInsights(services, config, env);
}

// AddMaskinportenClient adds a keyed service. This needs to happen after AddApplicationInsights,
// due to a bug in app insights: https://github.com/microsoft/ApplicationInsights-dotnet/issues/2828
services.AddMaskinportenClient();
services.AddCorrespondenceClient();

AddAuthenticationScheme(services, config, env);
AddAuthorizationPolicies(services);
AddAntiforgery(services);
Expand Down Expand Up @@ -159,23 +165,6 @@ string configSectionPath
return services;
}

/// <summary>
/// Adds a singleton <see cref="AddMaskinportenClient"/> service to the service collection.
/// If no <see cref="MaskinportenSettings"/> configuration is found, it binds one to the path "MaskinportenSettings".
/// </summary>
/// <param name="services">The service collection</param>
private static IServiceCollection AddMaskinportenClient(this IServiceCollection services)
{
if (services.GetOptionsDescriptor<MaskinportenSettings>() is null)
{
services.ConfigureMaskinportenClient("MaskinportenSettings");
}

services.AddSingleton<IMaskinportenClient, MaskinportenClient>();

return services;
}

/// <summary>
/// Adds Application Insights to the service collection.
/// </summary>
Expand Down Expand Up @@ -492,19 +481,6 @@ private static void AddAntiforgery(IServiceCollection services)
services.TryAddSingleton<ValidateAntiforgeryTokenIfAuthCookieAuthorizationFilter>();
}

private static IServiceCollection RemoveOptions<TOptions>(this IServiceCollection services)
where TOptions : class
{
var descriptor = services.GetOptionsDescriptor<TOptions>();

if (descriptor is not null)
{
services.Remove(descriptor);
}

return services;
}

private static (string? Key, string? ConnectionString) GetAppInsightsConfig(
IConfiguration config,
IHostEnvironment env
Expand Down
39 changes: 11 additions & 28 deletions src/Altinn.App.Api/Extensions/WebHostBuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
using Altinn.App.Core.Extensions;
using Microsoft.Extensions.FileProviders;
using Altinn.App.Core.Features.Maskinporten.Extensions;

namespace Altinn.App.Api.Extensions;

Expand Down Expand Up @@ -29,36 +29,19 @@ public static void ConfigureAppWebHost(this IWebHostBuilder builder, string[] ar

configBuilder.AddInMemoryCollection(config);

configBuilder.AddMaskinportenSettingsFile(context);
configBuilder.AddMaskinportenSettingsFile(
context,
"MaskinportenSettingsFilepath",
"/mnt/app-secrets/maskinporten-settings.json"
);
configBuilder.AddMaskinportenSettingsFile(
context,
"MaskinportenSettingsInternalFilepath",
"/mnt/app-secrets/maskinporten-settings-internal.json"
);

configBuilder.LoadAppConfig(args);
}
);
}

private static IConfigurationBuilder AddMaskinportenSettingsFile(
this IConfigurationBuilder configurationBuilder,
WebHostBuilderContext context
)
{
string jsonProvidedPath =
context.Configuration.GetValue<string>("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;
}
}
159 changes: 79 additions & 80 deletions src/Altinn.App.Api/Helpers/RequestHandling/RequestPartValidator.cs
Original file line number Diff line number Diff line change
@@ -1,115 +1,114 @@
using Altinn.Platform.Storage.Interface.Models;

namespace Altinn.App.Api.Helpers.RequestHandling
namespace Altinn.App.Api.Helpers.RequestHandling;

/// <summary>
/// Represents a validator of a single <see cref="RequestPart"/> with the help of app metadata
/// </summary>
public class RequestPartValidator
{
private readonly Application _appInfo;

/// <summary>
/// Represents a validator of a single <see cref="RequestPart"/> with the help of app metadata
/// Initialises a new instance of the <see cref="RequestPartValidator"/> class with the given application info.
/// </summary>
public class RequestPartValidator
/// <param name="appInfo">The application metadata to use when validating a <see cref="RequestPart"/>.</param>
public RequestPartValidator(Application appInfo)
{
private readonly Application _appInfo;
_appInfo = appInfo;
}

/// <summary>
/// Initialises a new instance of the <see cref="RequestPartValidator"/> class with the given application info.
/// </summary>
/// <param name="appInfo">The application metadata to use when validating a <see cref="RequestPart"/>.</param>
public RequestPartValidator(Application appInfo)
/// <summary>
/// Operation that can validate a <see cref="RequestPart"/> using the internal <see cref="Application"/>.
/// </summary>
/// <param name="part">The request part to be validated.</param>
/// <returns>null if no errors where found. Otherwise an error message.</returns>
public string? ValidatePart(RequestPart part)
{
if (part.Name == "instance")
{
_appInfo = appInfo;
}
if (!part.ContentType.StartsWith("application/json", StringComparison.Ordinal))
{
return $"Unexpected Content-Type '{part.ContentType}' of embedded instance template. Expecting 'application/json'";
}

/// <summary>
/// Operation that can validate a <see cref="RequestPart"/> using the internal <see cref="Application"/>.
/// </summary>
/// <param name="part">The request part to be validated.</param>
/// <returns>null if no errors where found. Otherwise an error message.</returns>
public string? ValidatePart(RequestPart part)
//// TODO: Validate that the element can be read as an instance?
}
else
{
if (part.Name == "instance")
DataType? dataType = _appInfo.DataTypes.Find(e => e.Id == part.Name);
if (dataType == null)
{
if (!part.ContentType.StartsWith("application/json", StringComparison.Ordinal))
{
return $"Unexpected Content-Type '{part.ContentType}' of embedded instance template. Expecting 'application/json'";
}
return $"Multipart section named, '{part.Name}' does not correspond to an element type in application metadata";
}

//// TODO: Validate that the element can be read as an instance?
if (part.ContentType == null)
{
return $"The multipart section named {part.Name} is missing Content-Type.";
}
else
{
DataType? dataType = _appInfo.DataTypes.Find(e => e.Id == part.Name);
if (dataType == null)
{
return $"Multipart section named, '{part.Name}' does not correspond to an element type in application metadata";
}

if (part.ContentType == null)
{
return $"The multipart section named {part.Name} is missing Content-Type.";
}
else
{
string contentTypeWithoutEncoding = part.ContentType.Split(";")[0];

// restrict content type if allowedContentTypes is specified
if (
dataType.AllowedContentTypes != null
&& dataType.AllowedContentTypes.Count > 0
&& !dataType.AllowedContentTypes.Contains(contentTypeWithoutEncoding)
)
{
return $"The multipart section named {part.Name} has a Content-Type '{part.ContentType}' which is invalid for element type '{dataType.Id}'";
}
}

long contentSize = part.FileSize != 0 ? part.FileSize : part.Bytes.Length;

if (contentSize == 0)
{
return $"The multipart section named {part.Name} has no data. Cannot process empty part.";
}
string contentTypeWithoutEncoding = part.ContentType.Split(";")[0];

// restrict content type if allowedContentTypes is specified
if (
dataType.MaxSize.HasValue
&& dataType.MaxSize > 0
&& contentSize > (long)dataType.MaxSize.Value * 1024 * 1024
dataType.AllowedContentTypes != null
&& dataType.AllowedContentTypes.Count > 0
&& !dataType.AllowedContentTypes.Contains(contentTypeWithoutEncoding)
)
{
return $"The multipart section named {part.Name} exceeds the size limit of element type '{dataType.Id}'";
return $"The multipart section named {part.Name} has a Content-Type '{part.ContentType}' which is invalid for element type '{dataType.Id}'";
}
}

return null;
long contentSize = part.FileSize != 0 ? part.FileSize : part.Bytes.Length;

if (contentSize == 0)
{
return $"The multipart section named {part.Name} has no data. Cannot process empty part.";
}

if (
dataType.MaxSize.HasValue
&& dataType.MaxSize > 0
&& contentSize > (long)dataType.MaxSize.Value * 1024 * 1024
)
{
return $"The multipart section named {part.Name} exceeds the size limit of element type '{dataType.Id}'";
}
}

/// <summary>
/// Operation that can validate a list of <see cref="RequestPart"/> elements using the internal <see cref="Application"/>.
/// </summary>
/// <param name="parts">The list of request parts to be validated.</param>
/// <returns>null if no errors where found. Otherwise an error message.</returns>
public string? ValidateParts(List<RequestPart> parts)
return null;
}

/// <summary>
/// Operation that can validate a list of <see cref="RequestPart"/> elements using the internal <see cref="Application"/>.
/// </summary>
/// <param name="parts">The list of request parts to be validated.</param>
/// <returns>null if no errors where found. Otherwise an error message.</returns>
public string? ValidateParts(List<RequestPart> parts)
{
foreach (RequestPart part in parts)
{
foreach (RequestPart part in parts)
string? partError = ValidatePart(part);
if (partError != null)
{
string? partError = ValidatePart(part);
if (partError != null)
{
return partError;
}
return partError;
}
}

foreach (DataType dataType in _appInfo.DataTypes)
foreach (DataType dataType in _appInfo.DataTypes)
{
if (dataType.MaxCount > 0)
{
if (dataType.MaxCount > 0)
int partCount = parts.Count(p => p.Name == dataType.Id);
if (dataType.MaxCount < partCount)
{
int partCount = parts.Count(p => p.Name == dataType.Id);
if (dataType.MaxCount < partCount)
{
return $"The list of parts contains more elements of type '{dataType.Id}' than the element type allows.";
}
return $"The list of parts contains more elements of type '{dataType.Id}' than the element type allows.";
}
}

return null;
}

return null;
}
}
5 changes: 5 additions & 0 deletions src/Altinn.App.Core/Configuration/PlatformSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ public class PlatformSettings
/// </summary>
public string ApiNotificationEndpoint { get; set; } = "http://localhost:5101/notifications/api/v1/";

/// <summary>
/// Gets or sets the url for the Correspondence API endpoint.
/// </summary>
public string ApiCorrespondenceEndpoint { get; set; } = "http://localhost:5101/correspondence/api/v1/"; // TODO: which port for localtest?

/// <summary>
/// Gets or sets the subscription key value to use in requests against the platform.
/// A new subscription key is generated automatically every time an app is deployed to an environment. The new key is then automatically
Expand Down
Loading

0 comments on commit 9cb9bc6

Please sign in to comment.