Skip to content

Commit ebc3e47

Browse files
authored
Secret provider (#1706)
* feat: add provider and generator * feat: add signed jwt to callback url * feat: add validator * chore: format * test: update existing * test: snapshots and updates * feat: add services to DI * temp: logging * fix path and add temp logging * refactor: log cleanup * feat: read app codes * refactor: remove aync from sync methods * feat: add namespace to appcodesettings * refactor: pr feedback * di: fix using * di: simplify options registration and usage, consolidate with existing * feat: instrument generator and validator * test: make test stricter * refactor: rename file * refactor: make room for more in AppCodesSettings * feat: metadata app codes
1 parent 50bc80b commit ebc3e47

17 files changed

Lines changed: 910 additions & 110 deletions

src/Altinn.App.Api/Controllers/NotificationCallbackController.cs

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Text.Json.Serialization;
22
using Altinn.App.Core.Features;
33
using Altinn.App.Core.Features.Notifications.Cancellation;
4+
using Altinn.App.Core.Features.Notifications.SecretProvider;
45
using Altinn.App.Core.Internal.Instances;
56
using Altinn.Platform.Storage.Interface.Models;
67
using Microsoft.AspNetCore.Authorization;
@@ -18,28 +19,34 @@ namespace Altinn.App.Api.Controllers;
1819
public class NotificationCallbackController(
1920
ILogger<NotificationCallbackController> logger,
2021
ICancelInstantiationNotification instantiationNotification,
22+
INotificationConditionCodeValidator validator,
2123
IInstanceClient instanceClient
22-
)
24+
) : ControllerBase
2325
{
2426
/// <summary>
2527
/// Callback endpoint to check whether remaining notifications on application instantiation should be sent or not
2628
/// </summary>
2729
/// <returns><see cref="NotificationCallbackResponse"/></returns>
2830
[HttpGet("{instanceOwnerPartyId:int}/{instanceGuid:guid}")]
31+
[ProducesResponseType(typeof(NotificationCallbackResponse), StatusCodes.Status200OK)]
32+
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
2933
public async Task<ActionResult<NotificationCallbackResponse>> NotificationCallback(
3034
[FromRoute] string org,
3135
[FromRoute] string app,
3236
[FromRoute] int instanceOwnerPartyId,
33-
[FromRoute] Guid instanceGuid
37+
[FromRoute] Guid instanceGuid,
38+
[FromQuery] string? code
3439
)
3540
{
36-
logger.LogInformation(
37-
"Received callback for org:{Org}, app:{App}, instanceOwnerPartyId:{InstanceOwnerPartyId}, instanceGuid:{InstanceGuid}.",
38-
org,
39-
app,
40-
instanceOwnerPartyId,
41-
instanceGuid
42-
);
41+
bool isValid = await validator.ValidateCode(code, instanceGuid);
42+
if (isValid is false)
43+
{
44+
logger.LogWarning(
45+
"Notification callback rejected: invalid or missing code for instance {InstanceGuid}.",
46+
instanceGuid
47+
);
48+
return Unauthorized();
49+
}
4350

4451
Instance? instance = null;
4552
try

src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using Altinn.App.Core.Features.Notifications.Cancellation;
1212
using Altinn.App.Core.Features.Notifications.Email;
1313
using Altinn.App.Core.Features.Notifications.Future;
14+
using Altinn.App.Core.Features.Notifications.SecretProvider;
1415
using Altinn.App.Core.Features.Notifications.Sms;
1516
using Altinn.App.Core.Features.Options;
1617
using Altinn.App.Core.Features.Options.Altinn3LibraryCodeList;
@@ -33,6 +34,7 @@
3334
using Altinn.App.Core.Infrastructure.Clients.Pdf;
3435
using Altinn.App.Core.Infrastructure.Clients.Profile;
3536
using Altinn.App.Core.Infrastructure.Clients.Register;
37+
using Altinn.App.Core.Infrastructure.Clients.Secrets;
3638
using Altinn.App.Core.Infrastructure.Clients.Storage;
3739
using Altinn.App.Core.Internal;
3840
using Altinn.App.Core.Internal.AltinnCdn;
@@ -100,6 +102,7 @@ IWebHostEnvironment env
100102
services.Configure<GeneralSettings>(configuration.GetSection("GeneralSettings"));
101103
services.Configure<PlatformSettings>(configuration.GetSection("PlatformSettings"));
102104
services.Configure<CacheSettings>(configuration.GetSection("CacheSettings"));
105+
services.Configure<AppCodesSettings>(configuration.GetSection("AppCodes"));
103106

104107
AddApplicationIdentifier(services);
105108

@@ -285,6 +288,9 @@ private static void AddNotificationServices(IServiceCollection services)
285288
services.AddHttpClient<INotificationOrderClient, NotificationOrderClient>();
286289
services.TryAddTransient<INotificationService, NotificationService>();
287290
services.TryAddTransient<ICancelInstantiationNotification, SendOnProcessNotEnded>();
291+
services.TryAddSingleton<INotificationConditionSecretProvider, NotificationConditionSecretProvider>();
292+
services.TryAddSingleton<INotificationConditionTokenGenerator, NotificationConditionTokenGenerator>();
293+
services.TryAddSingleton<INotificationConditionCodeValidator, NotificationConditionCodeValidator>();
288294
}
289295

290296
private static void AddPdfServices(IServiceCollection services)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using Altinn.App.Core.Exceptions;
2+
3+
namespace Altinn.App.Core.Features.Notifications.Exceptions;
4+
5+
internal class NotificationConditionSecretNotFoundException : AltinnException
6+
{
7+
public NotificationConditionSecretNotFoundException(string message)
8+
: base(message) { }
9+
}

src/Altinn.App.Core/Features/Notifications/NotificationService.cs

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Altinn.App.Core.Configuration;
2+
using Altinn.App.Core.Features.Notifications.SecretProvider;
23
using Altinn.App.Core.Features.Notifications.Texts;
34
using Altinn.App.Core.Internal.AltinnCdn;
45
using Altinn.App.Core.Internal.App;
@@ -18,6 +19,7 @@ namespace Altinn.App.Core.Features.Notifications;
1819
internal sealed class NotificationService : INotificationService
1920
{
2021
private readonly INotificationOrderClient _notificationOrderClient;
22+
private readonly INotificationConditionTokenGenerator _tokenGenerator;
2123
private readonly IProfileClient _profileClient;
2224
private readonly IAltinnCdnClient _cdnClient;
2325
private readonly IAppMetadata _appMetadata;
@@ -27,6 +29,7 @@ internal sealed class NotificationService : INotificationService
2729

2830
public NotificationService(
2931
INotificationOrderClient notificationOrderClient,
32+
INotificationConditionTokenGenerator tokenGenerator,
3033
IProfileClient profileClient,
3134
IAltinnCdnClient cdnClient,
3235
IAppMetadata appMetadata,
@@ -36,6 +39,7 @@ ILogger<NotificationService> logger
3639
)
3740
{
3841
_notificationOrderClient = notificationOrderClient;
42+
_tokenGenerator = tokenGenerator;
3943
_profileClient = profileClient;
4044
_cdnClient = cdnClient;
4145
_appMetadata = appMetadata;
@@ -57,6 +61,7 @@ CancellationToken ct
5761
AltinnCdnOrgName? serviceOwnerName = await _cdnClient.GetOrgNameByAppId(instance.AppId, ct);
5862
ApplicationMetadata? appMetadata = await _appMetadata.GetApplicationMetadata();
5963
string baseUrl = _generalSettings.FormattedExternalAppBaseUrl(new AppIdentifier(instance.AppId));
64+
Uri callBackUri = CallbackUrlWithAuth(instance, baseUrl);
6065

6166
NotificationOrderRequest orderRequest = CreateNotificationOrderRequest(
6267
language,
@@ -65,15 +70,21 @@ CancellationToken ct
6570
party.Name,
6671
serviceOwnerName,
6772
instantiationNotification,
68-
baseUrl
73+
callBackUri
6974
);
7075

76+
NotificationOrderResponse orderResponse = await _notificationOrderClient.Order(orderRequest, ct);
77+
7178
_logger.LogInformation(
72-
"Sending notification order with reference: {SendersReference}",
73-
orderRequest.SendersReference
79+
"Notification order created. OrderId: {OrderId}, ShipmentId: {ShipmentId}, Reference: {SendersReference}, ReminderCount: {ReminderCount}, ReminderShipmentIds: {ReminderShipmentIds}",
80+
orderResponse.OrderChainId,
81+
orderResponse.Notification.ShipmentId,
82+
orderRequest.SendersReference,
83+
orderRequest.Reminders?.Count ?? 0,
84+
orderResponse.Reminders.Count > 0
85+
? string.Join(", ", orderResponse.Reminders.Select(r => r.ShipmentId))
86+
: "none"
7487
);
75-
76-
await _notificationOrderClient.Order(orderRequest, ct);
7788
}
7889

7990
internal static NotificationOrderRequest CreateNotificationOrderRequest(
@@ -83,7 +94,7 @@ internal static NotificationOrderRequest CreateNotificationOrderRequest(
8394
string? instanceOwnerName,
8495
AltinnCdnOrgName? serviceOwnerName,
8596
InstantiationNotification instantiationNotification,
86-
string? callBackBaseUrl
97+
Uri conditionEndpoint
8798
)
8899
{
89100
InstanceOwner instanceOwner = instance.InstanceOwner;
@@ -163,14 +174,6 @@ internal static NotificationOrderRequest CreateNotificationOrderRequest(
163174
string sendersReference = "app-" + instance.Id;
164175
string idempotencyId = instance.Id + "-init";
165176

166-
Uri? conditionEndpoint = null;
167-
if (instantiationNotification.RequestedSendTime is not null)
168-
{
169-
conditionEndpoint = new Uri(
170-
callBackBaseUrl?.TrimEnd('/') + "/api/v1/notification-webhook-listener/" + instance.Id
171-
);
172-
}
173-
174177
if (string.IsNullOrWhiteSpace(instanceOwner.OrganisationNumber) is false)
175178
{
176179
NotificationRecipient recipient = new()
@@ -272,6 +275,17 @@ internal static NotificationOrderRequest CreateNotificationOrderRequest(
272275
);
273276
}
274277

278+
internal Uri CallbackUrlWithAuth(Instance instance, string callBackBaseUrl)
279+
{
280+
InstanceIdentifier instanceIdentifier = new(instance.Id);
281+
string token = _tokenGenerator.GenerateToken(instanceIdentifier.InstanceGuid);
282+
283+
var uriBuilder = new UriBuilder(callBackBaseUrl.TrimEnd('/'));
284+
uriBuilder.Path = uriBuilder.Path.TrimEnd('/') + $"/api/v1/notification-webhook-listener/{instance.Id}";
285+
uriBuilder.Query = $"code={Uri.EscapeDataString(token)}";
286+
return uriBuilder.Uri;
287+
}
288+
275289
private static List<NotificationReminder>? BuildReminders(
276290
string language,
277291
NotificationRecipient requestedRecipient,
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
using System.Diagnostics;
2+
using System.Text;
3+
using Altinn.App.Core.Features.Notifications.Exceptions;
4+
using Altinn.App.Core.Infrastructure.Clients.Secrets;
5+
using Microsoft.Extensions.Logging;
6+
using Microsoft.IdentityModel.JsonWebTokens;
7+
using Microsoft.IdentityModel.Tokens;
8+
9+
namespace Altinn.App.Core.Features.Notifications.SecretProvider;
10+
11+
/// <summary>
12+
/// Validates JWT codes used for notification condition endpoints.
13+
/// </summary>
14+
public interface INotificationConditionCodeValidator
15+
{
16+
/// <summary>
17+
/// Validates that the provided code is a valid JWT signed with the notification condition secret,
18+
/// and that it was issued for the specified instance.
19+
/// </summary>
20+
Task<bool> ValidateCode(string? code, Guid instanceGuid, Telemetry? telemetry = null);
21+
}
22+
23+
/// <inheritdoc />
24+
internal sealed class NotificationConditionCodeValidator(
25+
INotificationConditionSecretProvider secretProvider,
26+
ILogger<NotificationConditionCodeValidator> logger
27+
) : INotificationConditionCodeValidator
28+
{
29+
public async Task<bool> ValidateCode(string? code, Guid instanceGuid, Telemetry? telemetry = null)
30+
{
31+
using var activity = telemetry?.StartNotificationConditionValidateActivity(instanceGuid);
32+
33+
if (string.IsNullOrWhiteSpace(code))
34+
{
35+
logger.LogWarning("Notification condition code validation failed: no code provided.");
36+
return false;
37+
}
38+
39+
IReadOnlyList<AppCode> secrets;
40+
try
41+
{
42+
secrets = secretProvider.GetValidationSecrets();
43+
}
44+
catch (NotificationConditionSecretNotFoundException ex)
45+
{
46+
logger.LogWarning(ex, "Notification condition code validation failed - secrets not found.");
47+
activity?.SetStatus(
48+
ActivityStatusCode.Error,
49+
"Notification condition code validation failed - secrets not found."
50+
);
51+
return false;
52+
}
53+
54+
JsonWebTokenHandler handler = new();
55+
56+
// Read secret_id from token without validation to find the right secret
57+
JsonWebToken jwt;
58+
try
59+
{
60+
jwt = handler.ReadJsonWebToken(code);
61+
}
62+
catch (Exception ex)
63+
{
64+
logger.LogWarning(ex, "Notification condition code validation failed: could not read token.");
65+
activity?.SetStatus(ActivityStatusCode.Error, "Could not read token.");
66+
return false;
67+
}
68+
69+
string? secretId = jwt.GetClaim("secret_id")?.Value;
70+
AppCode? appCode = secretId is not null
71+
? secrets.FirstOrDefault(s => s.Id == secretId)
72+
: secrets.FirstOrDefault();
73+
74+
if (appCode is null)
75+
{
76+
logger.LogWarning(
77+
"Notification condition code validation failed: no secret found for secret_id {SecretId} for instance {InstanceGuid}.",
78+
secretId,
79+
instanceGuid
80+
);
81+
activity?.SetStatus(ActivityStatusCode.Error, "No secret found for token secret_id.");
82+
return false;
83+
}
84+
85+
SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(appCode.Code));
86+
TokenValidationResult result = await handler.ValidateTokenAsync(
87+
code,
88+
new TokenValidationParameters
89+
{
90+
ValidateIssuerSigningKey = true,
91+
IssuerSigningKey = key,
92+
ValidateIssuer = false,
93+
ValidateAudience = false,
94+
ValidateLifetime = true,
95+
ClockSkew = TimeSpan.FromMinutes(5),
96+
}
97+
);
98+
99+
if (!result.IsValid)
100+
{
101+
logger.LogWarning(
102+
"Notification condition code validation failed: token is invalid for instance {InstanceGuid}. Reason: {Reason}",
103+
instanceGuid,
104+
result.Exception?.Message
105+
);
106+
activity?.SetStatus(ActivityStatusCode.Error, "Token validation failed.");
107+
return false;
108+
}
109+
110+
bool jtiMatches =
111+
result.Claims.TryGetValue(JwtRegisteredClaimNames.Jti, out object? jti)
112+
&& jti?.ToString() == instanceGuid.ToString();
113+
114+
if (!jtiMatches)
115+
{
116+
logger.LogWarning(
117+
"Notification condition code validation failed: jti claim {Jti} does not match instanceGuid {InstanceGuid}.",
118+
jti,
119+
instanceGuid
120+
);
121+
activity?.SetStatus(ActivityStatusCode.Error, "Token jti did not match instance guid.");
122+
return false;
123+
}
124+
125+
return true;
126+
}
127+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using Altinn.App.Core.Features.Notifications.Exceptions;
2+
using Altinn.App.Core.Infrastructure.Clients.Secrets;
3+
using Microsoft.Extensions.Options;
4+
5+
namespace Altinn.App.Core.Features.Notifications.SecretProvider;
6+
7+
/// <summary>
8+
/// Provides a secret used for signing and validating notification condition endpoint JWT tokens.
9+
/// </summary>
10+
internal interface INotificationConditionSecretProvider
11+
{
12+
/// <summary>
13+
/// Gets the secret used for signing JWT tokens for notification condition endpoints.
14+
/// </summary>
15+
AppCode GetSigningSecret();
16+
17+
/// <summary>
18+
/// Get the currently available secrets for validation.
19+
/// </summary>
20+
IReadOnlyList<AppCode> GetValidationSecrets();
21+
}
22+
23+
/// <inheritdoc />
24+
internal sealed class NotificationConditionSecretProvider(IOptionsMonitor<AppCodesSettings> options)
25+
: INotificationConditionSecretProvider
26+
{
27+
/// <inheritdoc />
28+
public AppCode GetSigningSecret()
29+
{
30+
var codes = options.CurrentValue.NotificationCallback;
31+
if (codes is null or { Count: 0 })
32+
throw new NotificationConditionSecretNotFoundException(
33+
"AppCodes:Monthly is not configured. Ensure the app-codes secret is mounted."
34+
);
35+
return codes[0];
36+
}
37+
38+
public IReadOnlyList<AppCode> GetValidationSecrets()
39+
{
40+
var codes = options.CurrentValue.NotificationCallback;
41+
if (codes is null or { Count: 0 })
42+
throw new NotificationConditionSecretNotFoundException(
43+
"AppCodes:Monthly is not configured. Ensure the app-codes secret is mounted."
44+
);
45+
return codes;
46+
}
47+
}

0 commit comments

Comments
 (0)