Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions Altinn.Authorization.Utils.sln

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="CommunityToolkit.Diagnostics" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\ServiceDefaults.HttpClient\Altinn.Authorization.ServiceDefaults.HttpClient.csproj" />
<ProjectReference Include="..\ServiceDefaults.Telemetry\Altinn.Authorization.ServiceDefaults.Telemetry.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
namespace Altinn.Authorization.ServiceDefaults.HttpClient.MaskinPorten.Handlers;

/// <summary>
/// A message handler that automatically acquires and attaches Maskinporten access tokens to outgoing HTTP requests
/// based on per-request configuration.
/// </summary>
/// <remarks>MaskinPortenHandler inspects each HTTP request for a configured Maskinporten client name. If present,
/// it ensures a valid Maskinporten access token is attached as a Bearer token in the Authorization header. If a valid
/// token is already present and matches the configured client name, it is reused; otherwise, a new token is acquired.
/// This handler is intended for use in HTTP pipelines where Maskinporten authentication is required on a per-request
/// basis. Requests without a configured client key are passed through without modification. This handler should be
/// added after any retry handlers.</remarks>
internal sealed class MaskinPortenHandler
: AsyncOnlyDelegatingHandler
{
private readonly TimeProvider _timeProvider;
private readonly IMaskinPortenClient _client;

/// <summary>
/// Initializes a new <see cref="MaskinPortenHandler"/>.
/// </summary>
internal MaskinPortenHandler(TimeProvider timeProvider, IMaskinPortenClient client)
{
_timeProvider = timeProvider;
_client = client;
}

/// <inheritdoc/>
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (!request.Options.TryGetMaskinPortenClientName(out var clientName))
{
// no MaskinPorten client-key configured, so we do not run on this request
return base.SendAsync(request, cancellationToken);
}

if (request.Headers.Authorization is { Scheme: string scheme, Parameter: string token })
{
if (!string.Equals(scheme, "Bearer", StringComparison.OrdinalIgnoreCase))
{
// Existing non-bearer token, do not modify
return base.SendAsync(request, cancellationToken);
}

return HandleExistingToken(request, clientName, token, cancellationToken);
}

// fetch a new token and send the request
return AddToken(request, clientName, cancellationToken);
}

private Task<HttpResponseMessage> HandleExistingToken(HttpRequestMessage request, string clientName, string token, CancellationToken cancellationToken)
{
if (!request.Options.TryGetMaskinPortenToken(out var tokenObj)
|| tokenObj.AccessToken != token)
{
// token is added manually/not a MaskinPorten token, so we leave it alone
return base.SendAsync(request, cancellationToken);
}

if (!string.Equals(clientName, tokenObj.CacheKey.ClientName, StringComparison.Ordinal))
{
// Token belongs to a different client - this should not happen
throw new InvalidOperationException($"client-name and cache-key.client-name configured in request does not match");
}

var now = _timeProvider.GetUtcNow();
if (tokenObj.ValidTo > now)
{
// token is still valid, so just send it
return base.SendAsync(request, cancellationToken);
}

// token is expired - this can happen if there are retries in the pipeline for instance
// fetch a new token and send the request
return AddToken(request, clientName, cancellationToken);
}

private async Task<HttpResponseMessage> AddToken(HttpRequestMessage request, string clientName, CancellationToken cancellationToken)
{
var token = await _client.GetAccessToken(clientName, cancellationToken);
request.Options.MaskinPortenToken = token;
request.Headers.Authorization = new("Bearer", token.AccessToken);

return await base.SendAsync(request, cancellationToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using CommunityToolkit.Diagnostics;

namespace Altinn.Authorization.ServiceDefaults.HttpClient.MaskinPorten.Handlers;

/// <summary>
/// A handler that configures a request's MaskinPorten client name.
/// </summary>
/// <remarks>
/// This handler sets the MaskinPorten client name in the request options, allowing downstream handlers
/// to identify which MaskinPorten client configuration to use for the request.
/// </remarks>
internal sealed class MaskinPortenRequestConfigHandler
: AsyncOnlyDelegatingHandler
{
private readonly string _clientName;

/// <summary>
/// Initializes a new instance of the <see cref="MaskinPortenRequestConfigHandler"/> class.
/// </summary>
/// <param name="clientName">The MaskinPorten client name.</param>
public MaskinPortenRequestConfigHandler(string clientName)
{
Guard.IsNotNullOrEmpty(clientName);
_clientName = clientName;
}

/// <inheritdoc/>
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Options.MaskinPortenClientName ??= _clientName;

return base.SendAsync(request, cancellationToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Altinn.Authorization.ServiceDefaults.HttpClient.MaskinPorten;

/// <summary>
/// Defines a contract for retrieving access tokens from Maskinporten.
/// </summary>
public interface IMaskinPortenClient
{
/// <summary>
/// Retrieves an access token from Maskinporten using the specified client name.
/// </summary>
/// <param name="clientName">The name (configuration key) of the client.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/>.</param>
/// <returns>An access-token from MaskinPorten.</returns>
public Task<MaskinPortenToken> GetAccessToken(string clientName, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
namespace Altinn.Authorization.ServiceDefaults.HttpClient.MaskinPorten;

/// <summary>
/// Represents a MaskinPorten client cache-key.
/// </summary>
/// <notes>
/// This is used as a cache-key and a service-key, so it's important that all properties are immutable and
/// equatable. Care should be taken when modifying this class to ensure that equality semantics remain correct.
/// </notes>
internal sealed record class MaskinPortenCacheKey
{
/// <summary>
/// Initializes a new instance of the <see cref="MaskinPortenCacheKey"/> class.
/// </summary>
/// <param name="clientName">See <see cref="ClientName"/>.</param>
/// <param name="clientId">See <see cref="ClientId"/>.</param>
/// <param name="scope">See <see cref="Scope"/>.</param>
/// <param name="resource">See <see cref="Resource"/>.</param>
/// <param name="consumerOrg">See <see cref="ConsumerOrg"/>.</param>
public MaskinPortenCacheKey(
string clientName,
string clientId,
string scope,
string? resource,
string? consumerOrg)
{
ClientName = clientName;
ClientId = clientId;
Scope = scope;
Resource = resource;
ConsumerOrg = consumerOrg;
}

/// <summary>
/// Gets the name of the MaskinPorten client. Not sent to MaskinPorten.
/// </summary>
public string ClientName { get; }

/// <summary>
/// Gets the <c>client_id</c> used when getting tokens.
/// </summary>
/// <remarks>
/// The client ID is also used as a configuration key.
/// </remarks>
public string ClientId { get; }

/// <summary>
/// Gets the <c>scope</c> used when getting tokens.
/// </summary>
public string Scope { get; }

/// <summary>
/// Gets the <c>resource</c> used when getting tokens (optional).
/// </summary>
public string? Resource { get; }

/// <summary>
/// Gets the <c>consumer_org</c> used when getting tokens (optional).
/// </summary>
public string? ConsumerOrg { get; }
}
Loading
Loading