From 12e4e3a82f6102d7f2260af8ff3bb837598a5cbb Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 11 Jan 2026 23:55:29 +0100 Subject: [PATCH 1/7] Prevent sessions query from refetching when modal is not open --- .../WebApp/federated-modules/sideMenu/FederatedSideMenu.tsx | 2 +- .../WebApp/federated-modules/topMenu/AvatarButton.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/application/account-management/WebApp/federated-modules/sideMenu/FederatedSideMenu.tsx b/application/account-management/WebApp/federated-modules/sideMenu/FederatedSideMenu.tsx index 3c1013bbc..00d2e4e6d 100644 --- a/application/account-management/WebApp/federated-modules/sideMenu/FederatedSideMenu.tsx +++ b/application/account-management/WebApp/federated-modules/sideMenu/FederatedSideMenu.tsx @@ -67,7 +67,7 @@ export default function FederatedSideMenu({ currentSystem }: Readonly - + {isSessionsModalOpen && } !open && setInvitationDialogTenant(null)} diff --git a/application/account-management/WebApp/federated-modules/topMenu/AvatarButton.tsx b/application/account-management/WebApp/federated-modules/topMenu/AvatarButton.tsx index 860e8ee8c..3fd386dfa 100644 --- a/application/account-management/WebApp/federated-modules/topMenu/AvatarButton.tsx +++ b/application/account-management/WebApp/federated-modules/topMenu/AvatarButton.tsx @@ -93,7 +93,7 @@ export default function AvatarButton() { - + {isSessionsModalOpen && } ); } From 3157116d280e9adcaf2c0e0562c9aa5a3699179b Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 12 Jan 2026 01:42:42 +0100 Subject: [PATCH 2/7] Fix session revoke mutation to properly handle API errors --- .../common/SessionsModal.tsx | 66 +++++++++++++++---- .../WebApp/shared/lib/api/client.ts | 2 +- .../shared/translations/locale/da-DK.po | 10 ++- .../shared/translations/locale/en-US.po | 10 ++- .../e2e/session-management-flows.spec.ts | 6 +- .../infrastructure/http/queryClient.ts | 2 +- 6 files changed, 71 insertions(+), 25 deletions(-) diff --git a/application/account-management/WebApp/federated-modules/common/SessionsModal.tsx b/application/account-management/WebApp/federated-modules/common/SessionsModal.tsx index daaf70c32..1c2da015d 100644 --- a/application/account-management/WebApp/federated-modules/common/SessionsModal.tsx +++ b/application/account-management/WebApp/federated-modules/common/SessionsModal.tsx @@ -9,11 +9,11 @@ import { Heading } from "@repo/ui/components/Heading"; import { Modal } from "@repo/ui/components/Modal"; import { toastQueue } from "@repo/ui/components/Toast"; import { formatDate } from "@repo/utils/date/formatDate"; -import { useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { InfoIcon, LaptopIcon, LoaderIcon, MonitorIcon, SmartphoneIcon, TabletIcon, XIcon } from "lucide-react"; import { useState } from "react"; import { SmartDate } from "@/shared/components/SmartDate"; -import { api, type components, DeviceType } from "@/shared/lib/api/client"; +import { api, apiClient, type components, DeviceType } from "@/shared/lib/api/client"; type UserSessionInfo = components["schemas"]["UserSessionInfo"]; @@ -88,8 +88,9 @@ function getDeviceTypeLabel(deviceType: UserSessionInfo["deviceType"]): string { function SessionCard({ session, + isRevoking, onRevoke -}: Readonly<{ session: UserSessionInfo; onRevoke: (session: UserSessionInfo) => void }>) { +}: Readonly<{ session: UserSessionInfo; isRevoking: boolean; onRevoke: (session: UserSessionInfo) => void }>) { const deviceLabel = getDeviceTypeLabel(session.deviceType); const browserInfo = parseUserAgent(session.userAgent); @@ -127,8 +128,13 @@ function SessionCard({ {!session.isCurrent && ( - )} @@ -155,9 +161,9 @@ function RevokeSessionDialog({ cancelLabel={t`Cancel`} onAction={onRevoke} > - - Are you sure you want to revoke this session? The device will be signed out and will need to log in again. - + Are you sure you want to revoke this session? +
+ The device will need to sign in again. ); @@ -167,6 +173,7 @@ export default function SessionsModal({ isOpen, onOpenChange }: Readonly(null); const [isRevokeDialogOpen, setIsRevokeDialogOpen] = useState(false); const [hasRevokedSession, setHasRevokedSession] = useState(false); + const [revokingSessionIds, setRevokingSessionIds] = useState>(new Set()); const queryClient = useQueryClient(); const { data, isLoading } = api.useQuery("get", "/api/account-management/authentication/sessions", { @@ -175,17 +182,40 @@ export default function SessionsModal({ isOpen, onOpenChange }: Readonly { + const revokeSessionMutation = useMutation({ + mutationFn: async ({ sessionId }: { sessionId: string }) => { + const response = await apiClient.DELETE("/api/account-management/authentication/sessions/{id}", { + params: { path: { id: sessionId } } + }); + + if (response.error) { + throw response.error; + } + + return response.data; + }, + onSuccess: (_data, variables) => { + const sessionId = variables.sessionId; + setRevokingSessionIds((prev) => { + const next = new Set(prev); + next.delete(sessionId); + return next; + }); toastQueue.add({ title: t`Success`, description: t`Session revoked successfully`, variant: "success" }); queryClient.invalidateQueries({ queryKey: ["get", "/api/account-management/authentication/sessions"] }); - setIsRevokeDialogOpen(false); - setSelectedSession(null); setHasRevokedSession(true); + }, + onError: (_error, variables) => { + const sessionId = variables.sessionId; + setRevokingSessionIds((prev) => { + const next = new Set(prev); + next.delete(sessionId); + return next; + }); } }); @@ -196,7 +226,10 @@ export default function SessionsModal({ isOpen, onOpenChange }: Readonly { if (selectedSession) { - revokeSessionMutation.mutate({ params: { path: { id: selectedSession.id } } }); + setRevokingSessionIds((prev) => new Set(prev).add(selectedSession.id)); + setIsRevokeDialogOpen(false); + revokeSessionMutation.mutate({ sessionId: selectedSession.id }); + setSelectedSession(null); } }; @@ -248,7 +281,12 @@ export default function SessionsModal({ isOpen, onOpenChange }: Readonly {sessions.map((session) => ( - + ))} )} diff --git a/application/account-management/WebApp/shared/lib/api/client.ts b/application/account-management/WebApp/shared/lib/api/client.ts index 00f969045..5a084f71f 100644 --- a/application/account-management/WebApp/shared/lib/api/client.ts +++ b/application/account-management/WebApp/shared/lib/api/client.ts @@ -3,4 +3,4 @@ import type { components, paths } from "./api.generated"; export * from "./api.generated.d"; export type Schemas = components["schemas"]; -export const { api, queryClient } = createApiClient(); +export const { api, apiClient, queryClient } = createApiClient(); diff --git a/application/account-management/WebApp/shared/translations/locale/da-DK.po b/application/account-management/WebApp/shared/translations/locale/da-DK.po index f9925a1da..76fa8e037 100644 --- a/application/account-management/WebApp/shared/translations/locale/da-DK.po +++ b/application/account-management/WebApp/shared/translations/locale/da-DK.po @@ -143,8 +143,8 @@ msgstr "Er du sikker på, at du vil slette <0>{0} brugere permanent?" msgid "Are you sure you want to permanently delete <0>{userDisplayName}?" msgstr "Er du sikker på, at du vil slette <0>{userDisplayName} permanent?" -msgid "Are you sure you want to revoke this session? The device will be signed out and will need to log in again." -msgstr "Er du sikker på, at du vil tilbagekalde denne session? Enheden vil blive logget ud og skal logge ind igen." +msgid "Are you sure you want to revoke this session?" +msgstr "Er du sikker på, at du vil tilbagekalde denne session?" msgid "Audit reports and assessments" msgstr "Revisions rapporter og vurderinger" @@ -657,6 +657,9 @@ msgstr "Tilbagekald" msgid "Revoke session" msgstr "Tilbagekald session" +msgid "Revoking..." +msgstr "Tilbagekalder..." + msgid "Role" msgstr "Rolle" @@ -747,6 +750,9 @@ msgstr "Brugsvilkår" msgid "The agreement governing your use of our Service, including acceptable use and liability." msgstr "Aftalen der styrer din brug af vores service, inklusive acceptabel brug og ansvar." +msgid "The device will need to sign in again." +msgstr "Enheden skal logge ind igen." + msgid "The name of your account, shown to users and in email notifications" msgstr "Navnet på din konto, vist til brugere og i e-mail-notifikationer" diff --git a/application/account-management/WebApp/shared/translations/locale/en-US.po b/application/account-management/WebApp/shared/translations/locale/en-US.po index 0b2fe52bb..10b286d10 100644 --- a/application/account-management/WebApp/shared/translations/locale/en-US.po +++ b/application/account-management/WebApp/shared/translations/locale/en-US.po @@ -143,8 +143,8 @@ msgstr "Are you sure you want to permanently delete <0>{0} users?" msgid "Are you sure you want to permanently delete <0>{userDisplayName}?" msgstr "Are you sure you want to permanently delete <0>{userDisplayName}?" -msgid "Are you sure you want to revoke this session? The device will be signed out and will need to log in again." -msgstr "Are you sure you want to revoke this session? The device will be signed out and will need to log in again." +msgid "Are you sure you want to revoke this session?" +msgstr "Are you sure you want to revoke this session?" msgid "Audit reports and assessments" msgstr "Audit reports and assessments" @@ -657,6 +657,9 @@ msgstr "Revoke" msgid "Revoke session" msgstr "Revoke session" +msgid "Revoking..." +msgstr "Revoking..." + msgid "Role" msgstr "Role" @@ -747,6 +750,9 @@ msgstr "Terms of use" msgid "The agreement governing your use of our Service, including acceptable use and liability." msgstr "The agreement governing your use of our Service, including acceptable use and liability." +msgid "The device will need to sign in again." +msgstr "The device will need to sign in again." + msgid "The name of your account, shown to users and in email notifications" msgstr "The name of your account, shown to users and in email notifications" diff --git a/application/account-management/WebApp/tests/e2e/session-management-flows.spec.ts b/application/account-management/WebApp/tests/e2e/session-management-flows.spec.ts index 6ed7d8c76..3962eaf47 100644 --- a/application/account-management/WebApp/tests/e2e/session-management-flows.spec.ts +++ b/application/account-management/WebApp/tests/e2e/session-management-flows.spec.ts @@ -89,11 +89,7 @@ test.describe("@smoke", () => { await otherSessionCard.getByRole("button", { name: "Revoke" }).click(); await expect(page.getByRole("alertdialog", { name: "Revoke session" })).toBeVisible(); - await expect( - page.getByText( - "Are you sure you want to revoke this session? The device will be signed out and will need to log in again." - ) - ).toBeVisible(); + await expect(page.getByText("Are you sure you want to revoke this session?")).toBeVisible(); })(); await step("Cancel revoke dialog & verify session remains")(async () => { diff --git a/application/shared-webapp/infrastructure/http/queryClient.ts b/application/shared-webapp/infrastructure/http/queryClient.ts index 62ff3cfd0..1e10316ec 100644 --- a/application/shared-webapp/infrastructure/http/queryClient.ts +++ b/application/shared-webapp/infrastructure/http/queryClient.ts @@ -127,5 +127,5 @@ export function createApiClient

>() { // Create the TanStack Query client const api = createClient(apiClient); - return { api, queryClient }; + return { api, apiClient, queryClient }; } From dfe800edae6ab67955ae8fb3946bffd47e54345e Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 12 Jan 2026 02:28:36 +0100 Subject: [PATCH 3/7] Add session ID tracking to Application Insights and OpenTelemetry enrichers --- ...ApplicationInsightsTelemetryInitializer.cs | 1 + .../Telemetry/OpenTelemetryEnricher.cs | 1 + ...cationInsightsTelemetryInitializerTests.cs | 113 +++++++++++++++ .../Telemetry/OpenTelemetryEnricherTests.cs | 134 ++++++++++++++++++ 4 files changed, 249 insertions(+) create mode 100644 application/shared-kernel/Tests/Telemetry/ApplicationInsightsTelemetryInitializerTests.cs create mode 100644 application/shared-kernel/Tests/Telemetry/OpenTelemetryEnricherTests.cs diff --git a/application/shared-kernel/SharedKernel/Telemetry/ApplicationInsightsTelemetryInitializer.cs b/application/shared-kernel/SharedKernel/Telemetry/ApplicationInsightsTelemetryInitializer.cs index 3463fd282..6a36e138e 100644 --- a/application/shared-kernel/SharedKernel/Telemetry/ApplicationInsightsTelemetryInitializer.cs +++ b/application/shared-kernel/SharedKernel/Telemetry/ApplicationInsightsTelemetryInitializer.cs @@ -41,6 +41,7 @@ public void Initialize(ITelemetry telemetry) AddCustomProperty(telemetry, "user.is_authenticated", executionContext.UserInfo.IsAuthenticated); AddCustomProperty(telemetry, "user.locale", executionContext.UserInfo.Locale); AddCustomProperty(telemetry, "user.role", executionContext.UserInfo.Role); + AddCustomProperty(telemetry, "user.session_id", executionContext.UserInfo.SessionId?.Value); } public static void SetContext(IExecutionContext executionContext) diff --git a/application/shared-kernel/SharedKernel/Telemetry/OpenTelemetryEnricher.cs b/application/shared-kernel/SharedKernel/Telemetry/OpenTelemetryEnricher.cs index 8200218da..ba93f0761 100644 --- a/application/shared-kernel/SharedKernel/Telemetry/OpenTelemetryEnricher.cs +++ b/application/shared-kernel/SharedKernel/Telemetry/OpenTelemetryEnricher.cs @@ -22,5 +22,6 @@ public void Apply() Activity.Current.SetTag("user.is_authenticated", executionContext.UserInfo.IsAuthenticated); Activity.Current.SetTag("user.locale", executionContext.UserInfo.Locale); Activity.Current.SetTag("user.role", executionContext.UserInfo.Role); + Activity.Current.SetTag("user.session_id", executionContext.UserInfo.SessionId?.Value); } } diff --git a/application/shared-kernel/Tests/Telemetry/ApplicationInsightsTelemetryInitializerTests.cs b/application/shared-kernel/Tests/Telemetry/ApplicationInsightsTelemetryInitializerTests.cs new file mode 100644 index 000000000..2b1b5e52b --- /dev/null +++ b/application/shared-kernel/Tests/Telemetry/ApplicationInsightsTelemetryInitializerTests.cs @@ -0,0 +1,113 @@ +using System.Net; +using FluentAssertions; +using Microsoft.ApplicationInsights.DataContracts; +using NSubstitute; +using PlatformPlatform.SharedKernel.Authentication; +using PlatformPlatform.SharedKernel.Authentication.TokenGeneration; +using PlatformPlatform.SharedKernel.Domain; +using PlatformPlatform.SharedKernel.ExecutionContext; +using PlatformPlatform.SharedKernel.Telemetry; +using Xunit; + +namespace PlatformPlatform.SharedKernel.Tests.Telemetry; + +public sealed class ApplicationInsightsTelemetryInitializerTests +{ + [Fact] + public void Initialize_WhenUserIsAuthenticated_ShouldSetSessionIdInTelemetry() + { + // Arrange + var sessionId = SessionId.NewId(); + var userId = UserId.NewId(); + var tenantId = new TenantId(12345); + var userInfo = new UserInfo + { + IsAuthenticated = true, + Id = userId, + TenantId = tenantId, + SessionId = sessionId, + Locale = "en-US", + Role = "Admin", + IsInternalUser = false + }; + + var executionContext = Substitute.For(); + executionContext.UserInfo.Returns(userInfo); + executionContext.TenantId.Returns(tenantId); + executionContext.ClientIpAddress.Returns(IPAddress.Parse("192.168.1.1")); + + ApplicationInsightsTelemetryInitializer.SetContext(executionContext); + + var telemetry = new RequestTelemetry(); + var initializer = new ApplicationInsightsTelemetryInitializer(); + + // Act + initializer.Initialize(telemetry); + + // Assert + telemetry.Context.GlobalProperties.Should().ContainKey("user.session_id"); + telemetry.Context.GlobalProperties["user.session_id"].Should().Be(sessionId.Value); + } + + [Fact] + public void Initialize_WhenUserIsNotAuthenticated_ShouldNotSetSessionIdInTelemetry() + { + // Arrange + var userInfo = new UserInfo + { + IsAuthenticated = false, + Locale = "en-US", + IsInternalUser = false + }; + + var executionContext = Substitute.For(); + executionContext.UserInfo.Returns(userInfo); + executionContext.TenantId.Returns((TenantId?)null); + executionContext.ClientIpAddress.Returns(IPAddress.Parse("192.168.1.1")); + + ApplicationInsightsTelemetryInitializer.SetContext(executionContext); + + var telemetry = new RequestTelemetry(); + var initializer = new ApplicationInsightsTelemetryInitializer(); + + // Act + initializer.Initialize(telemetry); + + // Assert + telemetry.Context.GlobalProperties.Should().NotContainKey("user.session_id"); + } + + [Fact] + public void Initialize_WhenSessionIdIsNull_ShouldNotSetSessionIdInTelemetry() + { + // Arrange + var userId = UserId.NewId(); + var tenantId = new TenantId(12345); + var userInfo = new UserInfo + { + IsAuthenticated = true, + Id = userId, + TenantId = tenantId, + SessionId = null, + Locale = "en-US", + Role = "Admin", + IsInternalUser = false + }; + + var executionContext = Substitute.For(); + executionContext.UserInfo.Returns(userInfo); + executionContext.TenantId.Returns(tenantId); + executionContext.ClientIpAddress.Returns(IPAddress.Parse("192.168.1.1")); + + ApplicationInsightsTelemetryInitializer.SetContext(executionContext); + + var telemetry = new RequestTelemetry(); + var initializer = new ApplicationInsightsTelemetryInitializer(); + + // Act + initializer.Initialize(telemetry); + + // Assert + telemetry.Context.GlobalProperties.Should().NotContainKey("user.session_id"); + } +} diff --git a/application/shared-kernel/Tests/Telemetry/OpenTelemetryEnricherTests.cs b/application/shared-kernel/Tests/Telemetry/OpenTelemetryEnricherTests.cs new file mode 100644 index 000000000..2a332be21 --- /dev/null +++ b/application/shared-kernel/Tests/Telemetry/OpenTelemetryEnricherTests.cs @@ -0,0 +1,134 @@ +using System.Net; +using FluentAssertions; +using NSubstitute; +using PlatformPlatform.SharedKernel.Authentication; +using PlatformPlatform.SharedKernel.Authentication.TokenGeneration; +using PlatformPlatform.SharedKernel.Domain; +using PlatformPlatform.SharedKernel.ExecutionContext; +using PlatformPlatform.SharedKernel.Telemetry; +using Xunit; + +namespace PlatformPlatform.SharedKernel.Tests.Telemetry; + +public sealed class OpenTelemetryEnricherTests +{ + private static ActivitySamplingResult SampleAllData(ref ActivityCreationOptions options) + { + return ActivitySamplingResult.AllData; + } + + [Fact] + public void Apply_WhenUserIsAuthenticated_ShouldSetSessionIdTag() + { + // Arrange + var sessionId = SessionId.NewId(); + var userId = UserId.NewId(); + var tenantId = new TenantId(12345); + var userInfo = new UserInfo + { + IsAuthenticated = true, + Id = userId, + TenantId = tenantId, + SessionId = sessionId, + Locale = "en-US", + Role = "Admin", + IsInternalUser = false + }; + + var executionContext = Substitute.For(); + executionContext.UserInfo.Returns(userInfo); + executionContext.TenantId.Returns(tenantId); + executionContext.ClientIpAddress.Returns(IPAddress.Parse("192.168.1.1")); + + var enricher = new OpenTelemetryEnricher(executionContext); + + using var activitySource = new ActivitySource("TestSource"); + var listener = new ActivityListener(); + listener.ShouldListenTo = _ => true; + listener.Sample = SampleAllData; + using (listener) + { + ActivitySource.AddActivityListener(listener); + + using var activity = activitySource.StartActivity(); + activity.Should().NotBeNull(); + + // Act + enricher.Apply(); + + // Assert + var sessionIdTag = activity.Tags.FirstOrDefault(t => t.Key == "user.session_id"); + sessionIdTag.Value.Should().Be(sessionId.Value); + } + } + + [Fact] + public void Apply_WhenSessionIdIsNull_ShouldSetNullSessionIdTag() + { + // Arrange + var userId = UserId.NewId(); + var tenantId = new TenantId(12345); + var userInfo = new UserInfo + { + IsAuthenticated = true, + Id = userId, + TenantId = tenantId, + SessionId = null, + Locale = "en-US", + Role = "Admin", + IsInternalUser = false + }; + + var executionContext = Substitute.For(); + executionContext.UserInfo.Returns(userInfo); + executionContext.TenantId.Returns(tenantId); + executionContext.ClientIpAddress.Returns(IPAddress.Parse("192.168.1.1")); + + var enricher = new OpenTelemetryEnricher(executionContext); + + using var activitySource = new ActivitySource("TestSource"); + var listener = new ActivityListener(); + listener.ShouldListenTo = _ => true; + listener.Sample = SampleAllData; + using (listener) + { + ActivitySource.AddActivityListener(listener); + + using var activity = activitySource.StartActivity(); + activity.Should().NotBeNull(); + + // Act + enricher.Apply(); + + // Assert + var sessionIdTag = activity.Tags.FirstOrDefault(t => t.Key == "user.session_id"); + sessionIdTag.Value.Should().BeNull(); + } + } + + [Fact] + public void Apply_WhenNoCurrentActivity_ShouldNotThrow() + { + // Arrange + var userInfo = new UserInfo + { + IsAuthenticated = true, + Id = UserId.NewId(), + SessionId = SessionId.NewId(), + Locale = "en-US", + Role = "Admin", + IsInternalUser = false + }; + + var executionContext = Substitute.For(); + executionContext.UserInfo.Returns(userInfo); + executionContext.TenantId.Returns((TenantId?)null); + executionContext.ClientIpAddress.Returns(IPAddress.Parse("192.168.1.1")); + + var enricher = new OpenTelemetryEnricher(executionContext); + + // Act & Assert - Should not throw when Activity.Current is null + var act = () => enricher.Apply(); + act.Should().NotThrow(); + } +} From 9fe694908c12a6f3aef9c4c987bbf62cd0ee0d78 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 12 Jan 2026 13:57:35 +0100 Subject: [PATCH 4/7] Add PublicApiEnumDocumentProcessor to expose enums in OpenAPI spec --- .../shared/lib/api/AccountManagement.Api.json | 9 +++++ .../ApiDependencyConfiguration.cs | 5 ++- .../OpenApi/PublicApiEnumDocumentProcessor.cs | 39 +++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 application/shared-kernel/SharedKernel/OpenApi/PublicApiEnumDocumentProcessor.cs diff --git a/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json b/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json index 92758aeff..14e9bc742 100644 --- a/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json +++ b/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json @@ -1995,6 +1995,15 @@ "type": "string" } } + }, + "SessionRevokedReason": { + "type": "string", + "enum": [ + "LoggedOut", + "Revoked", + "ReplayAttackDetected", + "SwitchTenant" + ] } } } diff --git a/application/shared-kernel/SharedKernel/Configuration/ApiDependencyConfiguration.cs b/application/shared-kernel/SharedKernel/Configuration/ApiDependencyConfiguration.cs index 92a41a294..c4b87d763 100644 --- a/application/shared-kernel/SharedKernel/Configuration/ApiDependencyConfiguration.cs +++ b/application/shared-kernel/SharedKernel/Configuration/ApiDependencyConfiguration.cs @@ -10,6 +10,7 @@ using PlatformPlatform.SharedKernel.Endpoints; using PlatformPlatform.SharedKernel.ExecutionContext; using PlatformPlatform.SharedKernel.Middleware; +using PlatformPlatform.SharedKernel.OpenApi; using PlatformPlatform.SharedKernel.SinglePageApp; using PlatformPlatform.SharedKernel.StronglyTypedIds; using PlatformPlatform.SharedKernel.Telemetry; @@ -155,7 +156,9 @@ private IServiceCollection AddOpenApiConfiguration(Assembly[] assemblies) var options = (SystemTextJsonSchemaGeneratorSettings)settings.SchemaSettings; options.SerializerOptions = SharedDependencyConfiguration.DefaultJsonSerializerOptions; - settings.DocumentProcessors.Add(new StronglyTypedDocumentProcessor(assemblies.Concat([Assembly.GetExecutingAssembly()]).ToArray())); + var allAssemblies = assemblies.Concat([Assembly.GetExecutingAssembly()]).ToArray(); + settings.DocumentProcessors.Add(new StronglyTypedDocumentProcessor(allAssemblies)); + settings.DocumentProcessors.Add(new PublicApiEnumDocumentProcessor(allAssemblies)); } ); } diff --git a/application/shared-kernel/SharedKernel/OpenApi/PublicApiEnumDocumentProcessor.cs b/application/shared-kernel/SharedKernel/OpenApi/PublicApiEnumDocumentProcessor.cs new file mode 100644 index 000000000..5bee4d7f8 --- /dev/null +++ b/application/shared-kernel/SharedKernel/OpenApi/PublicApiEnumDocumentProcessor.cs @@ -0,0 +1,39 @@ +using NJsonSchema; +using NSwag.Generation.Processors; +using NSwag.Generation.Processors.Contexts; + +namespace PlatformPlatform.SharedKernel.OpenApi; + +public sealed class PublicApiEnumDocumentProcessor(Assembly[] assemblies) : IDocumentProcessor +{ + public void Process(DocumentProcessorContext context) + { + foreach (var assembly in assemblies) + { + var enumsWithJsonStringConverter = assembly.GetTypes() + .Where(t => t.IsEnum) + .Where(t => t.GetCustomAttribute()?.ConverterType?.Name == nameof(JsonStringEnumConverter)) + .ToList(); + + foreach (var enumType in enumsWithJsonStringConverter) + { + if (context.Document.Components.Schemas.ContainsKey(enumType.Name)) + { + continue; + } + + var schema = new JsonSchema + { + Type = JsonObjectType.String + }; + + foreach (var enumValue in Enum.GetNames(enumType)) + { + schema.Enumeration.Add(enumValue); + } + + context.Document.Components.Schemas[enumType.Name] = schema; + } + } + } +} From 0b7fdc246c6e50d2324916f83468c6ca211c7794 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 12 Jan 2026 23:06:34 +0100 Subject: [PATCH 5/7] Ensure all 401 responses include unauthorized reason header and handle concurrent session revocation atomically --- .../AuthenticationCookieMiddleware.cs | 80 ++++++++++++++++++- .../Commands/RefreshAuthenticationTokens.cs | 46 +++++++---- .../Domain/SessionRepository.cs | 23 ++++++ .../Authentication/Domain/SessionTypes.cs | 5 ++ .../RefreshAuthenticationTokensTests.cs | 6 ++ .../SharedKernel/ApiResults/ApiResult.cs | 8 ++ .../AuthenticationTokenHttpKeys.cs | 2 + .../Authentication/UnauthorizedReason.cs | 16 ++++ .../shared-kernel/SharedKernel/Cqrs/Result.cs | 25 +++--- .../ValidationPipelineBehavior.cs | 2 +- 10 files changed, 184 insertions(+), 29 deletions(-) create mode 100644 application/shared-kernel/SharedKernel/Authentication/UnauthorizedReason.cs diff --git a/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs b/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs index a26a77263..e377771da 100644 --- a/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs +++ b/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs @@ -14,7 +14,8 @@ public class AuthenticationCookieMiddleware( ILogger logger ) : IMiddleware { - private const string? RefreshAuthenticationTokensEndpoint = "/internal-api/account-management/authentication/refresh-authentication-tokens"; + private const string RefreshAuthenticationTokensEndpoint = "/internal-api/account-management/authentication/refresh-authentication-tokens"; + private const string UnauthorizedReasonItemKey = "UnauthorizedReason"; public async Task InvokeAsync(HttpContext context, RequestDelegate next) { @@ -24,8 +25,32 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) await ValidateAuthenticationCookieAndConvertToHttpBearerHeader(context, refreshTokenCookieValue, accessTokenCookieValue); } + // If session was revoked during refresh, handle based on request type + if (context.Items.TryGetValue(UnauthorizedReasonItemKey, out var reason) && reason is string unauthorizedReason) + { + if (context.Request.Path.StartsWithSegments("/api")) + { + // For API requests: return 401 immediately so JavaScript can handle it + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.Headers[AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey] = unauthorizedReason; + return; + } + + // For non-API requests (SPA routes): delete cookies and let the page load + // The SPA will load without auth and redirect to login as needed + context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName); + context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName); + } + await next(context); + // Ensure all 401 responses have an unauthorized reason header for consistent frontend handling + if (context.Response.StatusCode == StatusCodes.Status401Unauthorized && + !context.Response.Headers.ContainsKey(AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey)) + { + context.Response.Headers[AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey] = nameof(UnauthorizedReason.SessionNotFound); + } + if (context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey, out _)) { logger.LogDebug("Refreshing authentication tokens as requested by endpoint"); @@ -71,12 +96,24 @@ private async Task ValidateAuthenticationCookieAndConvertToHttpBearerHeader(Http context.Request.Headers.Authorization = $"Bearer {accessToken}"; } + catch (SessionRevokedException ex) + { + DeleteCookiesForApiRequestsOnly(context); + context.Items[UnauthorizedReasonItemKey] = ex.RevokedReason; + logger.LogWarning(ex, "Session revoked during token refresh. Reason: {Reason}", ex.RevokedReason); + } catch (SecurityTokenException ex) { - context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName); - context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName); + DeleteCookiesForApiRequestsOnly(context); + context.Items[UnauthorizedReasonItemKey] = nameof(UnauthorizedReason.SessionNotFound); logger.LogWarning(ex, "Validating or refreshing the authentication token cookies failed. {Message}", ex.Message); } + catch (Exception ex) + { + DeleteCookiesForApiRequestsOnly(context); + context.Items[UnauthorizedReasonItemKey] = nameof(UnauthorizedReason.SessionNotFound); + logger.LogError(ex, "Unexpected exception during authentication token validation. Path: {Path}", context.Request.Path); + } } private async Task<(string newRefreshToken, string newAccessToken)> RefreshAuthenticationTokensAsync(string refreshToken) @@ -91,6 +128,12 @@ private async Task ValidateAuthenticationCookieAndConvertToHttpBearerHeader(Http if (!response.IsSuccessStatusCode) { + var unauthorizedReason = GetUnauthorizedReason(response); + if (unauthorizedReason is not null) + { + throw new SessionRevokedException(unauthorizedReason); + } + throw new SecurityTokenException($"Failed to refresh security tokens. Response status code: {response.StatusCode}."); } @@ -105,6 +148,32 @@ private async Task ValidateAuthenticationCookieAndConvertToHttpBearerHeader(Http return (newRefreshToken, newAccessToken); } + private static string? GetUnauthorizedReason(HttpResponseMessage response) + { + if (response.Headers.TryGetValues(AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey, out var values)) + { + return values.FirstOrDefault(); + } + + return null; + } + + ///

+ /// Only delete authentication cookies for API requests. For non-API requests (images, static assets), + /// keep the cookies so subsequent API requests can properly detect session issues like replay attacks. + /// The frontend's AuthenticationMiddleware only intercepts API responses, not image/asset errors. + /// + private static void DeleteCookiesForApiRequestsOnly(HttpContext context) + { + if (!context.Request.Path.StartsWithSegments("/api")) + { + return; + } + + context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName); + context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName); + } + private void ReplaceAuthenticationHeaderWithCookie(HttpContext context, string refreshToken, string accessToken) { var refreshTokenExpires = ExtractExpirationFromToken(refreshToken); @@ -148,3 +217,8 @@ private DateTimeOffset ExtractExpirationFromToken(string token) return DateTimeOffset.FromUnixTimeSeconds(long.Parse(expires)); } } + +public sealed class SessionRevokedException(string revokedReason) : SecurityTokenException($"Session has been revoked. Reason: {revokedReason}") +{ + public string RevokedReason { get; } = revokedReason; +} diff --git a/application/account-management/Core/Features/Authentication/Commands/RefreshAuthenticationTokens.cs b/application/account-management/Core/Features/Authentication/Commands/RefreshAuthenticationTokens.cs index 08282cde6..5b0a61920 100644 --- a/application/account-management/Core/Features/Authentication/Commands/RefreshAuthenticationTokens.cs +++ b/application/account-management/Core/Features/Authentication/Commands/RefreshAuthenticationTokens.cs @@ -5,6 +5,7 @@ using PlatformPlatform.AccountManagement.Features.Authentication.Domain; using PlatformPlatform.AccountManagement.Features.Users.Domain; using PlatformPlatform.AccountManagement.Features.Users.Shared; +using PlatformPlatform.SharedKernel.Authentication; using PlatformPlatform.SharedKernel.Authentication.TokenGeneration; using PlatformPlatform.SharedKernel.Cqrs; using PlatformPlatform.SharedKernel.Domain; @@ -26,45 +27,52 @@ public sealed class RefreshAuthenticationTokensHandler( ILogger logger ) : IRequestHandler { + private const string InvalidRefreshTokenMessage = "Invalid refresh token."; + public async Task Handle(RefreshAuthenticationTokensCommand command, CancellationToken cancellationToken) { var httpContext = httpContextAccessor.HttpContext ?? throw new InvalidOperationException("HttpContext is null."); + var invalidTokenHeaders = new Dictionary + { + { AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey, nameof(UnauthorizedReason.SessionNotFound) } + }; + if (!UserId.TryParse(httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId)) { logger.LogWarning("No valid 'sub' claim found in refresh token"); - return Result.Unauthorized("Invalid refresh token."); + return Result.Unauthorized(InvalidRefreshTokenMessage, responseHeaders: invalidTokenHeaders); } if (!SessionId.TryParse(httpContext.User.FindFirstValue("sid"), out var sessionId)) { logger.LogWarning("No valid 'sid' claim found in refresh token"); - return Result.Unauthorized("Invalid refresh token."); + return Result.Unauthorized(InvalidRefreshTokenMessage, responseHeaders: invalidTokenHeaders); } if (!RefreshTokenJti.TryParse(httpContext.User.FindFirstValue(JwtRegisteredClaimNames.Jti), out var jti)) { logger.LogWarning("No valid 'jti' claim found in refresh token"); - return Result.Unauthorized("Invalid refresh token."); + return Result.Unauthorized(InvalidRefreshTokenMessage, responseHeaders: invalidTokenHeaders); } if (!int.TryParse(httpContext.User.FindFirstValue("ver"), out var refreshTokenVersion)) { logger.LogWarning("No valid 'ver' claim found in refresh token"); - return Result.Unauthorized("Invalid refresh token."); + return Result.Unauthorized(InvalidRefreshTokenMessage, responseHeaders: invalidTokenHeaders); } var expiresClaim = httpContext.User.FindFirstValue(JwtRegisteredClaimNames.Exp); if (expiresClaim is null) { logger.LogWarning("No 'exp' claim found in refresh token"); - return Result.Unauthorized("Invalid refresh token."); + return Result.Unauthorized(InvalidRefreshTokenMessage, responseHeaders: invalidTokenHeaders); } if (!long.TryParse(expiresClaim, out var expiresUnixSeconds)) { logger.LogWarning("Invalid 'exp' claim format in refresh token"); - return Result.Unauthorized("Invalid refresh token."); + return Result.Unauthorized(InvalidRefreshTokenMessage, responseHeaders: invalidTokenHeaders); } var refreshTokenExpires = DateTimeOffset.FromUnixTimeSeconds(expiresUnixSeconds); @@ -74,19 +82,23 @@ public async Task Handle(RefreshAuthenticationTokensCommand command, Can if (session is null) { logger.LogWarning("No session found for session id '{SessionId}'", sessionId); - return Result.Unauthorized("Invalid refresh token."); + return Result.Unauthorized(InvalidRefreshTokenMessage, responseHeaders: invalidTokenHeaders); } if (session.IsRevoked) { - logger.LogWarning("Session '{SessionId}' has been revoked", session.Id); - return Result.Unauthorized("Session has been revoked."); + logger.LogWarning("Session '{SessionId}' has been revoked with reason '{RevokedReason}'", session.Id, session.RevokedReason); + var unauthorizedHeaders = new Dictionary + { + { AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey, session.RevokedReason?.ToString() ?? nameof(UnauthorizedReason.Revoked) } + }; + return Result.Unauthorized("Session has been revoked.", responseHeaders: unauthorizedHeaders); } if (session.UserId != userId) { logger.LogWarning("Session user id '{SessionUserId}' does not match token user id '{TokenUserId}'", session.UserId, userId); - return Result.Unauthorized("Invalid refresh token."); + return Result.Unauthorized(InvalidRefreshTokenMessage, responseHeaders: invalidTokenHeaders); } if (!session.IsRefreshTokenValid(jti, refreshTokenVersion, now)) @@ -95,17 +107,23 @@ public async Task Handle(RefreshAuthenticationTokensCommand command, Can "Replay attack detected for session '{SessionId}'. Token JTI '{TokenJti}', current JTI '{CurrentJti}'. Token version '{TokenVersion}', current version '{CurrentVersion}'", session.Id, jti, session.RefreshTokenJti, refreshTokenVersion, session.RefreshTokenVersion ); - session.Revoke(now, SessionRevokedReason.ReplayAttackDetected); - sessionRepository.Update(session); + + // Atomic revocation - only one concurrent request succeeds, but all return ReplayAttackDetected + await sessionRepository.TryRevokeForReplayUnfilteredAsync(sessionId, now, cancellationToken); + events.CollectEvent(new SessionReplayDetected(session.Id, refreshTokenVersion, session.RefreshTokenVersion)); - return Result.Unauthorized("Invalid refresh token. Session has been revoked due to potential replay attack.", true); + var unauthorizedHeaders = new Dictionary + { + { AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey, nameof(UnauthorizedReason.ReplayAttackDetected) } + }; + return Result.Unauthorized("Invalid refresh token. Session has been revoked due to potential replay attack.", true, unauthorizedHeaders); } var user = await userRepository.GetByIdAsync(userId, cancellationToken); if (user is null) { logger.LogWarning("No user found with user id '{UserId}'", userId); - return Result.Unauthorized($"No user found with user id '{userId}'."); + return Result.Unauthorized($"No user found with user id '{userId}'.", responseHeaders: invalidTokenHeaders); } RefreshTokenJti tokenJti; diff --git a/application/account-management/Core/Features/Authentication/Domain/SessionRepository.cs b/application/account-management/Core/Features/Authentication/Domain/SessionRepository.cs index 47a6cf3b1..73b9d9157 100644 --- a/application/account-management/Core/Features/Authentication/Domain/SessionRepository.cs +++ b/application/account-management/Core/Features/Authentication/Domain/SessionRepository.cs @@ -28,6 +28,14 @@ public interface ISessionRepository : ICrudRepository /// Returns false if another concurrent request already refreshed the session. /// Task TryRefreshAsync(SessionId sessionId, RefreshTokenJti currentJti, int currentVersion, RefreshTokenJti newJti, DateTimeOffset now, CancellationToken cancellationToken); + + /// + /// Attempts to revoke the session for a replay attack without applying tenant query filters. + /// Uses atomic update to handle concurrent requests - only one will succeed, but all callers + /// can safely return ReplayAttackDetected since the session will be revoked either way. + /// This method should only be used during token refresh where tenant context comes from the token claims. + /// + Task TryRevokeForReplayUnfilteredAsync(SessionId sessionId, DateTimeOffset now, CancellationToken cancellationToken); } public sealed class SessionRepository(AccountManagementDbContext accountManagementDbContext) @@ -75,6 +83,21 @@ UPDATE Sessions return rowsAffected == 1; } + public async Task TryRevokeForReplayUnfilteredAsync(SessionId sessionId, DateTimeOffset now, CancellationToken cancellationToken) + { + var rowsAffected = await DbSet + .IgnoreQueryFilters() + .Where(s => s.Id == sessionId && s.RevokedAt == null) + .ExecuteUpdateAsync(s => s + .SetProperty(x => x.RevokedAt, now) + .SetProperty(x => x.RevokedReason, SessionRevokedReason.ReplayAttackDetected) + .SetProperty(x => x.ModifiedAt, now), + cancellationToken + ); + + return rowsAffected == 1; + } + public async Task GetActiveSessionsForUserAsync(UserId userId, CancellationToken cancellationToken) { var sessions = await DbSet diff --git a/application/account-management/Core/Features/Authentication/Domain/SessionTypes.cs b/application/account-management/Core/Features/Authentication/Domain/SessionTypes.cs index e8730abf9..84e85c055 100644 --- a/application/account-management/Core/Features/Authentication/Domain/SessionTypes.cs +++ b/application/account-management/Core/Features/Authentication/Domain/SessionTypes.cs @@ -12,6 +12,11 @@ public enum DeviceType Tablet } +/// +/// Represents why a session was revoked. This is a domain concept stored in the Session aggregate. +/// For HTTP header reasons (which include additional cases like SessionNotFound), see +/// . +/// [PublicAPI] [JsonConverter(typeof(JsonStringEnumConverter))] public enum SessionRevokedReason diff --git a/application/account-management/Tests/Authentication/RefreshAuthenticationTokensTests.cs b/application/account-management/Tests/Authentication/RefreshAuthenticationTokensTests.cs index b7f7b4d51..38d0d46c1 100644 --- a/application/account-management/Tests/Authentication/RefreshAuthenticationTokensTests.cs +++ b/application/account-management/Tests/Authentication/RefreshAuthenticationTokensTests.cs @@ -84,6 +84,8 @@ public async Task RefreshAuthenticationTokens_WhenReplayAttackDetected_ShouldRev // Assert response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + response.Headers.Should().ContainKey("x-unauthorized-reason"); + response.Headers.GetValues("x-unauthorized-reason").Single().Should().Be("ReplayAttackDetected"); object[] parameters = [new { id = sessionId.ToString() }]; Connection.ExecuteScalar("SELECT RevokedAt FROM Sessions WHERE Id = @id", parameters).Should().NotBeNull(); @@ -110,6 +112,8 @@ public async Task RefreshAuthenticationTokens_WhenSessionRevoked_ShouldReturnUna // Assert response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + response.Headers.Should().ContainKey("x-unauthorized-reason"); + response.Headers.GetValues("x-unauthorized-reason").Single().Should().Be("Revoked"); TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); } @@ -128,6 +132,8 @@ public async Task RefreshAuthenticationTokens_WhenSessionNotFound_ShouldReturnUn // Assert response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + response.Headers.Should().ContainKey("x-unauthorized-reason"); + response.Headers.GetValues("x-unauthorized-reason").Single().Should().Be("SessionNotFound"); TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); } diff --git a/application/shared-kernel/SharedKernel/ApiResults/ApiResult.cs b/application/shared-kernel/SharedKernel/ApiResults/ApiResult.cs index 3fd3f80db..2c72b3dc2 100644 --- a/application/shared-kernel/SharedKernel/ApiResults/ApiResult.cs +++ b/application/shared-kernel/SharedKernel/ApiResults/ApiResult.cs @@ -24,6 +24,14 @@ public Task ExecuteAsync(HttpContext httpContext) } } + if (result.ResponseHeaders is not null) + { + foreach (var (key, value) in result.ResponseHeaders) + { + httpContext.Response.Headers[key] = value; + } + } + return ConvertResult().ExecuteAsync(httpContext); } diff --git a/application/shared-kernel/SharedKernel/Authentication/AuthenticationTokenHttpKeys.cs b/application/shared-kernel/SharedKernel/Authentication/AuthenticationTokenHttpKeys.cs index 0c6f1c95d..7a399d306 100644 --- a/application/shared-kernel/SharedKernel/Authentication/AuthenticationTokenHttpKeys.cs +++ b/application/shared-kernel/SharedKernel/Authentication/AuthenticationTokenHttpKeys.cs @@ -10,6 +10,8 @@ public static class AuthenticationTokenHttpKeys public const string RefreshAuthenticationTokensHeaderKey = "x-refresh-authentication-tokens-required"; + public const string UnauthorizedReasonHeaderKey = "x-unauthorized-reason"; + // __Host prefix ensures the cookie is sent only to the host, requires Secure, HTTPS, Path=/ and no Domain specified public const string RefreshTokenCookieName = "__Host_Refresh_Token"; diff --git a/application/shared-kernel/SharedKernel/Authentication/UnauthorizedReason.cs b/application/shared-kernel/SharedKernel/Authentication/UnauthorizedReason.cs new file mode 100644 index 000000000..e1a514405 --- /dev/null +++ b/application/shared-kernel/SharedKernel/Authentication/UnauthorizedReason.cs @@ -0,0 +1,16 @@ +namespace PlatformPlatform.SharedKernel.Authentication; + +/// +/// Represents why a request was rejected with 401 Unauthorized. +/// Used for the x-unauthorized-reason HTTP header to communicate specific reasons to clients. +/// Session revocation reasons map directly from SessionRevokedReason; SessionNotFound +/// indicates the session could not be found (no database record to store this on). +/// IMPORTANT: Must be kept in sync with UnauthorizedReason const in +/// shared-webapp/infrastructure/auth/AuthenticationMiddleware.ts +/// +public enum UnauthorizedReason +{ + Revoked, + ReplayAttackDetected, + SessionNotFound +} diff --git a/application/shared-kernel/SharedKernel/Cqrs/Result.cs b/application/shared-kernel/SharedKernel/Cqrs/Result.cs index 6688484cc..ddceeaf22 100644 --- a/application/shared-kernel/SharedKernel/Cqrs/Result.cs +++ b/application/shared-kernel/SharedKernel/Cqrs/Result.cs @@ -11,13 +11,14 @@ protected ResultBase(HttpStatusCode httpStatusCode) StatusCode = httpStatusCode; } - protected ResultBase(HttpStatusCode statusCode, ErrorMessage errorMessage, bool commitChanges, ErrorDetail[] errors) + protected ResultBase(HttpStatusCode statusCode, ErrorMessage errorMessage, bool commitChanges, ErrorDetail[] errors, IDictionary? responseHeaders = null) { IsSuccess = false; StatusCode = statusCode; ErrorMessage = errorMessage; CommitChangesOnFailure = commitChanges; Errors = errors; + ResponseHeaders = responseHeaders; } public bool IsSuccess { get; } @@ -30,6 +31,8 @@ protected ResultBase(HttpStatusCode statusCode, ErrorMessage errorMessage, bool public ErrorDetail[]? Errors { get; } + public IDictionary? ResponseHeaders { get; } + public string GetErrorSummary() { return ErrorMessage?.Message ?? string.Join(Environment.NewLine, Errors!.Select(ed => $"{ed.PropertyName}: {ed.Message}")); @@ -47,8 +50,8 @@ private Result(HttpStatusCode httpStatusCode) : base(httpStatusCode) { } - public Result(HttpStatusCode statusCode, ErrorMessage errorMessage, bool commitChanges, ErrorDetail[] errors) - : base(statusCode, errorMessage, commitChanges, errors) + public Result(HttpStatusCode statusCode, ErrorMessage? errorMessage, bool commitChanges, ErrorDetail[] errors, IDictionary? responseHeaders = null) + : base(statusCode, errorMessage!, commitChanges, errors, responseHeaders) { } @@ -62,9 +65,9 @@ public static Result BadRequest(string message, bool commitChanges = false) return new Result(HttpStatusCode.BadRequest, new ErrorMessage(message), commitChanges, []); } - public static Result Unauthorized(string message, bool commitChanges = false) + public static Result Unauthorized(string message, bool commitChanges = false, IDictionary? responseHeaders = null) { - return new Result(HttpStatusCode.Unauthorized, new ErrorMessage(message), commitChanges, []); + return new Result(HttpStatusCode.Unauthorized, new ErrorMessage(message), commitChanges, [], responseHeaders); } public static Result Forbidden(string message, bool commitChanges = false) @@ -89,7 +92,7 @@ public static Result TooManyRequests(string message, bool commitChanges = false) public static Result From(ResultBase result) { - return new Result(result.StatusCode, result.ErrorMessage!, result.CommitChangesOnFailure, result.Errors!); + return new Result(result.StatusCode, result.ErrorMessage!, result.CommitChangesOnFailure, result.Errors!, result.ResponseHeaders); } } @@ -105,8 +108,8 @@ private Result(T value, HttpStatusCode httpStatusCode) : base(httpStatusCode) Value = value; } - public Result(HttpStatusCode statusCode, ErrorMessage errorMessage, bool commitChanges, ErrorDetail[] errors) - : base(statusCode, errorMessage, commitChanges, errors) + public Result(HttpStatusCode statusCode, ErrorMessage? errorMessage, bool commitChanges, ErrorDetail[] errors, IDictionary? responseHeaders = null) + : base(statusCode, errorMessage!, commitChanges, errors, responseHeaders) { } @@ -126,9 +129,9 @@ public static Result BadRequest(string message, bool commitChanges = false) return new Result(HttpStatusCode.BadRequest, new ErrorMessage(message), commitChanges, []); } - public static Result Unauthorized(string message, bool commitChanges = false) + public static Result Unauthorized(string message, bool commitChanges = false, IDictionary? responseHeaders = null) { - return new Result(HttpStatusCode.Unauthorized, new ErrorMessage(message), commitChanges, []); + return new Result(HttpStatusCode.Unauthorized, new ErrorMessage(message), commitChanges, [], responseHeaders); } public static Result Forbidden(string message, bool commitChanges = false) @@ -167,6 +170,6 @@ public static implicit operator Result(T value) public static Result From(ResultBase result) { - return new Result(result.StatusCode, result.ErrorMessage!, result.CommitChangesOnFailure, result.Errors!); + return new Result(result.StatusCode, result.ErrorMessage!, result.CommitChangesOnFailure, result.Errors!, result.ResponseHeaders); } } diff --git a/application/shared-kernel/SharedKernel/PipelineBehaviors/ValidationPipelineBehavior.cs b/application/shared-kernel/SharedKernel/PipelineBehaviors/ValidationPipelineBehavior.cs index 36926639c..b2f436762 100644 --- a/application/shared-kernel/SharedKernel/PipelineBehaviors/ValidationPipelineBehavior.cs +++ b/application/shared-kernel/SharedKernel/PipelineBehaviors/ValidationPipelineBehavior.cs @@ -47,6 +47,6 @@ public async Task Handle(TRequest request, RequestHandlerDelegate(ErrorDetail[] errorDetails) where TResult : ResultBase { - return (TResult)Activator.CreateInstance(typeof(TResult), HttpStatusCode.BadRequest, null, false, errorDetails)!; + return (TResult)Activator.CreateInstance(typeof(TResult), HttpStatusCode.BadRequest, null, false, errorDetails, null)!; } } From 5d1a6d2087126ce5354fb46b3adaff986c9edf15 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 13 Jan 2026 02:31:28 +0100 Subject: [PATCH 6/7] Add generic error page for authentication errors --- application/AppGateway/appsettings.json | 6 + .../WebApp/routes/error.tsx | 162 ++++++++++++++++++ .../shared/translations/locale/da-DK.po | 27 +++ .../shared/translations/locale/en-US.po | 27 +++ .../auth/AuthenticationMiddleware.ts | 42 ++++- 5 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 application/account-management/WebApp/routes/error.tsx diff --git a/application/AppGateway/appsettings.json b/application/AppGateway/appsettings.json index cbdca9dfc..9f2529f0f 100644 --- a/application/AppGateway/appsettings.json +++ b/application/AppGateway/appsettings.json @@ -136,6 +136,12 @@ "Path": "/admin/{**catch-all}" } }, + "account-management-error": { + "ClusterId": "account-management-api", + "Match": { + "Path": "/error" + } + }, "account-management-login": { "ClusterId": "account-management-api", "Match": { diff --git a/application/account-management/WebApp/routes/error.tsx b/application/account-management/WebApp/routes/error.tsx new file mode 100644 index 000000000..b177e194a --- /dev/null +++ b/application/account-management/WebApp/routes/error.tsx @@ -0,0 +1,162 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { ErrorCode } from "@repo/infrastructure/auth/AuthenticationMiddleware"; +import { Button } from "@repo/ui/components/Button"; +import { Image } from "@repo/ui/components/Image"; +import { Link } from "@repo/ui/components/Link"; +import { createFileRoute, Navigate, useNavigate } from "@tanstack/react-router"; +import { AlertCircle, LogIn, LogOut, ShieldAlert } from "lucide-react"; +import type { ReactNode } from "react"; +import LocaleSwitcher from "@/federated-modules/common/LocaleSwitcher"; +import SupportButton from "@/federated-modules/common/SupportButton"; +import ThemeModeSelector from "@/federated-modules/common/ThemeModeSelector"; +import logoMark from "@/shared/images/logo-mark.svg"; +import logoWrap from "@/shared/images/logo-wrap.svg"; + +export const Route = createFileRoute("/error")({ + validateSearch: (search) => { + const params = search as { error?: string; returnPath?: string }; + return { + error: params.error, + returnPath: params.returnPath?.startsWith("/") ? params.returnPath : undefined + }; + }, + component: ErrorPage +}); + +function getErrorDisplay(error: string): { + icon: ReactNode; + iconBackground: string; + title: ReactNode; + message: ReactNode; +} { + switch (error) { + case ErrorCode.ReplayAttack: + return { + icon: , + iconBackground: "bg-destructive/10", + title: Security alert, + message: ( + <> + + We detected suspicious activity on your account. Someone may have attempted to take over your session. + +
+ For your protection, you have been logged out. Please log in again to continue. + + ) + }; + + case ErrorCode.SessionRevoked: + return { + icon: , + iconBackground: "bg-muted", + title: Session ended, + message: ( + <> + Your session was ended from another device. +
+ Please log in again to continue. + + ) + }; + + case ErrorCode.SessionNotFound: + return { + icon: , + iconBackground: "bg-muted", + title: Session expired, + message: ( + <> + Your session has expired. +
+ Please log in again to continue. + + ) + }; + + default: + return { + icon: , + iconBackground: "bg-destructive/10", + title: Something went wrong, + message: ( + An unexpected error occurred. Please try again or contact support if the problem persists. + ) + }; + } +} + +function ErrorNavigation() { + return ( + + ); +} + +function ErrorPage() { + const { error, returnPath } = Route.useSearch(); + const navigate = useNavigate(); + + if (!error) { + return ; + } + + const errorDisplay = getErrorDisplay(error); + + const handleLogIn = () => { + navigate({ to: "/login", search: { returnPath } }); + }; + + return ( +
+ + +
+
+
+ {errorDisplay.icon} +
+ +
+

{errorDisplay.title}

+

{errorDisplay.message}

+
+ +
+ +
+
+
+
+ ); +} diff --git a/application/account-management/WebApp/shared/translations/locale/da-DK.po b/application/account-management/WebApp/shared/translations/locale/da-DK.po index 76fa8e037..cd0bf35d7 100644 --- a/application/account-management/WebApp/shared/translations/locale/da-DK.po +++ b/application/account-management/WebApp/shared/translations/locale/da-DK.po @@ -120,6 +120,9 @@ msgstr "En e-mail med login-instruktioner vil blive sendt til brugeren." msgid "An unexpected error occurred while processing your request." msgstr "Der opstod en uventet fejl ved behandlingen." +msgid "An unexpected error occurred. Please try again or contact support if the problem persists." +msgstr "Der opstod en uventet fejl. Prøv igen eller kontakt support, hvis problemet fortsætter." + msgid "An unexpected state occurred. Please reload the page." msgstr "Der opstod en uventet tilstand. Venligst genindlæs siden." @@ -364,6 +367,9 @@ msgstr "Fornavn" msgid "For detailed compliance certifications and audit reports, visit Microsoft's official resources." msgstr "For detaljerede certificeringer og revisionsrapporter, besøg Microsofts officielle ressourcer." +msgid "For your protection, you have been logged out. Please log in again to continue." +msgstr "For din beskyttelse er du blevet logget ud. Log venligst ind igen for at fortsætte." + msgid "Free, open-source .NET and React starter kit for building modern SaaS applications." msgstr "Gratis, open-source .NET og React startpakke til at bygge moderne SaaS-applikationer." @@ -581,6 +587,9 @@ msgstr "Tjek venligst URL'en eller vend tilbage til forsiden." msgid "Please check your email for a verification code sent to <0>{email}" msgstr "Tjek din e-mail for en bekræftelseskode sendt til <0>{email}" +msgid "Please log in again to continue." +msgstr "Log venligst ind igen for at fortsætte." + msgid "Please note that it may take up to 5 minutes before the device is signed out." msgstr "Bemærk at det kan tage op til 5 minutter, før enheden er logget ud." @@ -675,6 +684,9 @@ msgstr "Skærmbilleder af dashboard-projektet med desktop- og mobilversioner" msgid "Search" msgstr "Søg" +msgid "Security alert" +msgstr "Sikkerhedsadvarsel" + msgid "Security, privacy, and compliance information" msgstr "Sikkerheds-, privatlivs- og overholdelsesoplysninger" @@ -690,6 +702,12 @@ msgstr "Send invitation" msgid "Sending verification code..." msgstr "Sender bekræftelseskode..." +msgid "Session ended" +msgstr "Session afsluttet" + +msgid "Session expired" +msgstr "Session udløbet" + msgid "Session revoked successfully" msgstr "Session tilbagekaldt succesfuldt" @@ -891,6 +909,9 @@ msgstr "Se profil" msgid "View users" msgstr "Se brugere" +msgid "We detected suspicious activity on your account. Someone may have attempted to take over your session." +msgstr "Vi opdagede mistænkelig aktivitet på din konto. Nogen kan have forsøgt at overtage din session." + msgid "Welcome home" msgstr "Velkommen hjem" @@ -935,6 +956,12 @@ msgstr "Dine data forbliver i den Azure region du vælger ved tilmelding" msgid "Your professional title or role" msgstr "Din professionelle titel eller rolle" +msgid "Your session has expired." +msgstr "Din session er udløbet." + +msgid "Your session was ended from another device." +msgstr "Din session blev afsluttet fra en anden enhed." + msgid "Your team needs to know who's inviting them. Add an account name to get started." msgstr "Dit team skal vide, hvem der inviterer dem. Tilføj et kontonavn for at komme i gang." diff --git a/application/account-management/WebApp/shared/translations/locale/en-US.po b/application/account-management/WebApp/shared/translations/locale/en-US.po index 10b286d10..bd527fcee 100644 --- a/application/account-management/WebApp/shared/translations/locale/en-US.po +++ b/application/account-management/WebApp/shared/translations/locale/en-US.po @@ -120,6 +120,9 @@ msgstr "An email with login instructions will be sent to the user." msgid "An unexpected error occurred while processing your request." msgstr "An unexpected error occurred while processing your request." +msgid "An unexpected error occurred. Please try again or contact support if the problem persists." +msgstr "An unexpected error occurred. Please try again or contact support if the problem persists." + msgid "An unexpected state occurred. Please reload the page." msgstr "An unexpected state occurred. Please reload the page." @@ -364,6 +367,9 @@ msgstr "First name" msgid "For detailed compliance certifications and audit reports, visit Microsoft's official resources." msgstr "For detailed compliance certifications and audit reports, visit Microsoft's official resources." +msgid "For your protection, you have been logged out. Please log in again to continue." +msgstr "For your protection, you have been logged out. Please log in again to continue." + msgid "Free, open-source .NET and React starter kit for building modern SaaS applications." msgstr "Free, open-source .NET and React starter kit for building modern SaaS applications." @@ -581,6 +587,9 @@ msgstr "Please check the URL or return to the home page." msgid "Please check your email for a verification code sent to <0>{email}" msgstr "Please check your email for a verification code sent to <0>{email}" +msgid "Please log in again to continue." +msgstr "Please log in again to continue." + msgid "Please note that it may take up to 5 minutes before the device is signed out." msgstr "Please note that it may take up to 5 minutes before the device is signed out." @@ -675,6 +684,9 @@ msgstr "Screenshots of the dashboard project with desktop and mobile versions" msgid "Search" msgstr "Search" +msgid "Security alert" +msgstr "Security alert" + msgid "Security, privacy, and compliance information" msgstr "Security, privacy, and compliance information" @@ -690,6 +702,12 @@ msgstr "Send invite" msgid "Sending verification code..." msgstr "Sending verification code..." +msgid "Session ended" +msgstr "Session ended" + +msgid "Session expired" +msgstr "Session expired" + msgid "Session revoked successfully" msgstr "Session revoked successfully" @@ -891,6 +909,9 @@ msgstr "View profile" msgid "View users" msgstr "View users" +msgid "We detected suspicious activity on your account. Someone may have attempted to take over your session." +msgstr "We detected suspicious activity on your account. Someone may have attempted to take over your session." + msgid "Welcome home" msgstr "Welcome home" @@ -935,6 +956,12 @@ msgstr "Your data stays in the Azure region you select at signup" msgid "Your professional title or role" msgstr "Your professional title or role" +msgid "Your session has expired." +msgstr "Your session has expired." + +msgid "Your session was ended from another device." +msgstr "Your session was ended from another device." + msgid "Your team needs to know who's inviting them. Add an account name to get started." msgstr "Your team needs to know who's inviting them. Add an account name to get started." diff --git a/application/shared-webapp/infrastructure/auth/AuthenticationMiddleware.ts b/application/shared-webapp/infrastructure/auth/AuthenticationMiddleware.ts index dcf1a3d7c..256e7bb2b 100644 --- a/application/shared-webapp/infrastructure/auth/AuthenticationMiddleware.ts +++ b/application/shared-webapp/infrastructure/auth/AuthenticationMiddleware.ts @@ -6,11 +6,51 @@ type AuthenticationMiddlewareOptions = { customLoginPath?: string; }; +// IMPORTANT: Must be kept in sync with UnauthorizedReason enum in shared-kernel/SharedKernel/Authentication/UnauthorizedReason.cs +const UnauthorizedReason = { + Revoked: "Revoked", + ReplayAttackDetected: "ReplayAttackDetected", + SessionNotFound: "SessionNotFound" +} as const; + +// Error codes used in /error page query parameter +export const ErrorCode = { + ReplayAttack: "replay-attack", + SessionRevoked: "session-revoked", + SessionNotFound: "session-not-found" +} as const; + +const unauthorizedReasonHeaderKey = "x-unauthorized-reason"; + +function getErrorCodeFromUnauthorizedReason(reason: string | null): string | null { + switch (reason) { + case UnauthorizedReason.ReplayAttackDetected: + return ErrorCode.ReplayAttack; + case UnauthorizedReason.Revoked: + return ErrorCode.SessionRevoked; + case UnauthorizedReason.SessionNotFound: + return ErrorCode.SessionNotFound; + default: + return null; + } +} + export function createAuthenticationMiddleware(options?: AuthenticationMiddlewareOptions): Middleware { return { onResponse(context) { if (context.response.status === 401) { - window.location.href = createLoginUrlWithReturnPath(options?.customLoginPath ?? loginPath); + const loginUrl = createLoginUrlWithReturnPath(options?.customLoginPath ?? loginPath); + const unauthorizedReason = context.response.headers.get(unauthorizedReasonHeaderKey); + const errorCode = getErrorCodeFromUnauthorizedReason(unauthorizedReason); + + if (errorCode) { + const errorUrl = new URL(loginUrl); + errorUrl.pathname = "/error"; + errorUrl.searchParams.set("error", errorCode); + globalThis.location.href = errorUrl.href; + } else { + globalThis.location.href = loginUrl; + } } } }; From bfb77c53cd619ebf5b288f3db0a6a79a8d837a70 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 13 Jan 2026 16:00:42 +0100 Subject: [PATCH 7/7] Use window.location.href for cross-system navigation in federated error pages --- .../errorPages/FederatedAccessDeniedPage.tsx | 17 ++++---- .../errorPages/FederatedErrorPage.tsx | 40 ++++++++++++++----- .../errorPages/FederatedNotFoundPage.tsx | 17 ++++---- 3 files changed, 45 insertions(+), 29 deletions(-) diff --git a/application/account-management/WebApp/federated-modules/errorPages/FederatedAccessDeniedPage.tsx b/application/account-management/WebApp/federated-modules/errorPages/FederatedAccessDeniedPage.tsx index 4fbdb48e8..67f65cb7a 100644 --- a/application/account-management/WebApp/federated-modules/errorPages/FederatedAccessDeniedPage.tsx +++ b/application/account-management/WebApp/federated-modules/errorPages/FederatedAccessDeniedPage.tsx @@ -5,7 +5,6 @@ import { loginPath } from "@repo/infrastructure/auth/constants"; import { useIsAuthenticated, useUserInfo } from "@repo/infrastructure/auth/hooks"; import { Button } from "@repo/ui/components/Button"; import { Image } from "@repo/ui/components/Image"; -import { Link } from "@repo/ui/components/Link"; import { Home, LogOut, ShieldX } from "lucide-react"; import { useContext, useState } from "react"; import logoMark from "@/shared/images/logo-mark.svg"; @@ -42,10 +41,10 @@ function AccessDeniedNavigation() { } }); if (response.ok) { - window.location.href = loginPath; + globalThis.location.href = loginPath; } } catch { - window.location.href = loginPath; + globalThis.location.href = loginPath; } }; @@ -112,15 +111,15 @@ export default function FederatedAccessDeniedPage() {
- { + globalThis.location.href = "/"; + }} > Go to home - +
diff --git a/application/account-management/WebApp/federated-modules/errorPages/FederatedErrorPage.tsx b/application/account-management/WebApp/federated-modules/errorPages/FederatedErrorPage.tsx index 0d3de1cae..817a77cb3 100644 --- a/application/account-management/WebApp/federated-modules/errorPages/FederatedErrorPage.tsx +++ b/application/account-management/WebApp/federated-modules/errorPages/FederatedErrorPage.tsx @@ -1,11 +1,11 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; +import { applicationInsights } from "@repo/infrastructure/applicationInsights/ApplicationInsightsProvider"; import { AuthenticationContext } from "@repo/infrastructure/auth/AuthenticationProvider"; import { loginPath } from "@repo/infrastructure/auth/constants"; import { useIsAuthenticated, useUserInfo } from "@repo/infrastructure/auth/hooks"; import { Button } from "@repo/ui/components/Button"; import { Image } from "@repo/ui/components/Image"; -import { Link } from "@repo/ui/components/Link"; import type { ErrorComponentProps } from "@tanstack/react-router"; import { AlertTriangle, Home, LogOut, RefreshCw } from "lucide-react"; import { useContext, useEffect, useState } from "react"; @@ -43,10 +43,10 @@ function ErrorNavigation() { } }); if (response.ok) { - window.location.href = loginPath; + globalThis.location.href = loginPath; } } catch { - window.location.href = loginPath; + globalThis.location.href = loginPath; } }; @@ -90,10 +90,23 @@ function ErrorNavigation() { ); } -export default function FederatedErrorPage({ error, reset }: Readonly) { +export default function FederatedErrorPage({ error }: Readonly) { const [showDetails, setShowDetails] = useState(false); useEffect(() => { + const exception = error instanceof Error ? error : new Error(String(error)); + + applicationInsights.trackException({ + exception, + properties: { + component: "ErrorPage", + url: globalThis.location.href, + pathname: globalThis.location.pathname, + errorName: exception.name, + errorMessage: exception.message + } + }); + console.error(error); }, [error]); @@ -119,19 +132,24 @@ export default function FederatedErrorPage({ error, reset }: Readonly
- - { + globalThis.location.href = "/"; + }} > Go to home - +
{error?.message && ( diff --git a/application/account-management/WebApp/federated-modules/errorPages/FederatedNotFoundPage.tsx b/application/account-management/WebApp/federated-modules/errorPages/FederatedNotFoundPage.tsx index 81ec15678..e92f579e7 100644 --- a/application/account-management/WebApp/federated-modules/errorPages/FederatedNotFoundPage.tsx +++ b/application/account-management/WebApp/federated-modules/errorPages/FederatedNotFoundPage.tsx @@ -5,7 +5,6 @@ import { loginPath } from "@repo/infrastructure/auth/constants"; import { useIsAuthenticated, useUserInfo } from "@repo/infrastructure/auth/hooks"; import { Button } from "@repo/ui/components/Button"; import { Image } from "@repo/ui/components/Image"; -import { Link } from "@repo/ui/components/Link"; import { FileQuestion, Home, LogOut } from "lucide-react"; import { useContext, useState } from "react"; import logoMark from "@/shared/images/logo-mark.svg"; @@ -42,10 +41,10 @@ function NotFoundNavigation() { } }); if (response.ok) { - window.location.href = loginPath; + globalThis.location.href = loginPath; } } catch { - window.location.href = loginPath; + globalThis.location.href = loginPath; } }; @@ -112,15 +111,15 @@ export default function FederatedNotFoundPage() {
- { + globalThis.location.href = "/"; + }} > Go to home - +