Skip to content

Commit b4eb972

Browse files
authored
Improve session security feedback with replay attack and revocation messages (#829)
### Summary & Motivation Previously, users were silently redirected to the login page whenever their session ended, with no explanation of what happened. Now users see a dedicated error page with a clear message explaining why they were logged out: - **Security alert**: When a replay attack is detected (someone attempted to reuse an old token) - **Session ended**: When a user's session was revoked from another device - **Session expired**: When the session could not be found or has expired This improves user experience by providing context and reassurance, especially in security-sensitive situations like replay attack detection. Supporting changes: - Add `x-unauthorized-reason` header to 401 responses so frontend can determine the logout reason - Add `user.session_id` to Application Insights and OpenTelemetry for session-based troubleshooting - Fix replay attack detection race condition where concurrent refresh requests could fail due to EF Core change tracking conflicts - now uses atomic `ExecuteUpdateAsync` that succeeds for one request and is a no-op for others - Disable the Revoke button while a session is being revoked to prevent double-click issues - Honor `skipQueryInvalidation` flag in TanStack Query's MutationCache to prevent sessions modal from refetching when closed ### Checklist - [x] I have added tests, or done manual regression tests - [x] I have updated the documentation, if necessary
2 parents d9bcd6d + bfb77c5 commit b4eb972

File tree

31 files changed

+866
-87
lines changed

31 files changed

+866
-87
lines changed

application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ public class AuthenticationCookieMiddleware(
1414
ILogger<AuthenticationCookieMiddleware> logger
1515
) : IMiddleware
1616
{
17-
private const string? RefreshAuthenticationTokensEndpoint = "/internal-api/account-management/authentication/refresh-authentication-tokens";
17+
private const string RefreshAuthenticationTokensEndpoint = "/internal-api/account-management/authentication/refresh-authentication-tokens";
18+
private const string UnauthorizedReasonItemKey = "UnauthorizedReason";
1819

1920
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
2021
{
@@ -24,8 +25,32 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next)
2425
await ValidateAuthenticationCookieAndConvertToHttpBearerHeader(context, refreshTokenCookieValue, accessTokenCookieValue);
2526
}
2627

28+
// If session was revoked during refresh, handle based on request type
29+
if (context.Items.TryGetValue(UnauthorizedReasonItemKey, out var reason) && reason is string unauthorizedReason)
30+
{
31+
if (context.Request.Path.StartsWithSegments("/api"))
32+
{
33+
// For API requests: return 401 immediately so JavaScript can handle it
34+
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
35+
context.Response.Headers[AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey] = unauthorizedReason;
36+
return;
37+
}
38+
39+
// For non-API requests (SPA routes): delete cookies and let the page load
40+
// The SPA will load without auth and redirect to login as needed
41+
context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName);
42+
context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName);
43+
}
44+
2745
await next(context);
2846

47+
// Ensure all 401 responses have an unauthorized reason header for consistent frontend handling
48+
if (context.Response.StatusCode == StatusCodes.Status401Unauthorized &&
49+
!context.Response.Headers.ContainsKey(AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey))
50+
{
51+
context.Response.Headers[AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey] = nameof(UnauthorizedReason.SessionNotFound);
52+
}
53+
2954
if (context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey, out _))
3055
{
3156
logger.LogDebug("Refreshing authentication tokens as requested by endpoint");
@@ -71,12 +96,24 @@ private async Task ValidateAuthenticationCookieAndConvertToHttpBearerHeader(Http
7196

7297
context.Request.Headers.Authorization = $"Bearer {accessToken}";
7398
}
99+
catch (SessionRevokedException ex)
100+
{
101+
DeleteCookiesForApiRequestsOnly(context);
102+
context.Items[UnauthorizedReasonItemKey] = ex.RevokedReason;
103+
logger.LogWarning(ex, "Session revoked during token refresh. Reason: {Reason}", ex.RevokedReason);
104+
}
74105
catch (SecurityTokenException ex)
75106
{
76-
context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName);
77-
context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName);
107+
DeleteCookiesForApiRequestsOnly(context);
108+
context.Items[UnauthorizedReasonItemKey] = nameof(UnauthorizedReason.SessionNotFound);
78109
logger.LogWarning(ex, "Validating or refreshing the authentication token cookies failed. {Message}", ex.Message);
79110
}
111+
catch (Exception ex)
112+
{
113+
DeleteCookiesForApiRequestsOnly(context);
114+
context.Items[UnauthorizedReasonItemKey] = nameof(UnauthorizedReason.SessionNotFound);
115+
logger.LogError(ex, "Unexpected exception during authentication token validation. Path: {Path}", context.Request.Path);
116+
}
80117
}
81118

82119
private async Task<(string newRefreshToken, string newAccessToken)> RefreshAuthenticationTokensAsync(string refreshToken)
@@ -91,6 +128,12 @@ private async Task ValidateAuthenticationCookieAndConvertToHttpBearerHeader(Http
91128

92129
if (!response.IsSuccessStatusCode)
93130
{
131+
var unauthorizedReason = GetUnauthorizedReason(response);
132+
if (unauthorizedReason is not null)
133+
{
134+
throw new SessionRevokedException(unauthorizedReason);
135+
}
136+
94137
throw new SecurityTokenException($"Failed to refresh security tokens. Response status code: {response.StatusCode}.");
95138
}
96139

@@ -105,6 +148,32 @@ private async Task ValidateAuthenticationCookieAndConvertToHttpBearerHeader(Http
105148
return (newRefreshToken, newAccessToken);
106149
}
107150

151+
private static string? GetUnauthorizedReason(HttpResponseMessage response)
152+
{
153+
if (response.Headers.TryGetValues(AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey, out var values))
154+
{
155+
return values.FirstOrDefault();
156+
}
157+
158+
return null;
159+
}
160+
161+
/// <summary>
162+
/// Only delete authentication cookies for API requests. For non-API requests (images, static assets),
163+
/// keep the cookies so subsequent API requests can properly detect session issues like replay attacks.
164+
/// The frontend's AuthenticationMiddleware only intercepts API responses, not image/asset errors.
165+
/// </summary>
166+
private static void DeleteCookiesForApiRequestsOnly(HttpContext context)
167+
{
168+
if (!context.Request.Path.StartsWithSegments("/api"))
169+
{
170+
return;
171+
}
172+
173+
context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName);
174+
context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName);
175+
}
176+
108177
private void ReplaceAuthenticationHeaderWithCookie(HttpContext context, string refreshToken, string accessToken)
109178
{
110179
var refreshTokenExpires = ExtractExpirationFromToken(refreshToken);
@@ -148,3 +217,8 @@ private DateTimeOffset ExtractExpirationFromToken(string token)
148217
return DateTimeOffset.FromUnixTimeSeconds(long.Parse(expires));
149218
}
150219
}
220+
221+
public sealed class SessionRevokedException(string revokedReason) : SecurityTokenException($"Session has been revoked. Reason: {revokedReason}")
222+
{
223+
public string RevokedReason { get; } = revokedReason;
224+
}

application/AppGateway/appsettings.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,12 @@
136136
"Path": "/admin/{**catch-all}"
137137
}
138138
},
139+
"account-management-error": {
140+
"ClusterId": "account-management-api",
141+
"Match": {
142+
"Path": "/error"
143+
}
144+
},
139145
"account-management-login": {
140146
"ClusterId": "account-management-api",
141147
"Match": {

application/account-management/Core/Features/Authentication/Commands/RefreshAuthenticationTokens.cs

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using PlatformPlatform.AccountManagement.Features.Authentication.Domain;
66
using PlatformPlatform.AccountManagement.Features.Users.Domain;
77
using PlatformPlatform.AccountManagement.Features.Users.Shared;
8+
using PlatformPlatform.SharedKernel.Authentication;
89
using PlatformPlatform.SharedKernel.Authentication.TokenGeneration;
910
using PlatformPlatform.SharedKernel.Cqrs;
1011
using PlatformPlatform.SharedKernel.Domain;
@@ -26,45 +27,52 @@ public sealed class RefreshAuthenticationTokensHandler(
2627
ILogger<RefreshAuthenticationTokensHandler> logger
2728
) : IRequestHandler<RefreshAuthenticationTokensCommand, Result>
2829
{
30+
private const string InvalidRefreshTokenMessage = "Invalid refresh token.";
31+
2932
public async Task<Result> Handle(RefreshAuthenticationTokensCommand command, CancellationToken cancellationToken)
3033
{
3134
var httpContext = httpContextAccessor.HttpContext ?? throw new InvalidOperationException("HttpContext is null.");
3235

36+
var invalidTokenHeaders = new Dictionary<string, string>
37+
{
38+
{ AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey, nameof(UnauthorizedReason.SessionNotFound) }
39+
};
40+
3341
if (!UserId.TryParse(httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
3442
{
3543
logger.LogWarning("No valid 'sub' claim found in refresh token");
36-
return Result.Unauthorized("Invalid refresh token.");
44+
return Result.Unauthorized(InvalidRefreshTokenMessage, responseHeaders: invalidTokenHeaders);
3745
}
3846

3947
if (!SessionId.TryParse(httpContext.User.FindFirstValue("sid"), out var sessionId))
4048
{
4149
logger.LogWarning("No valid 'sid' claim found in refresh token");
42-
return Result.Unauthorized("Invalid refresh token.");
50+
return Result.Unauthorized(InvalidRefreshTokenMessage, responseHeaders: invalidTokenHeaders);
4351
}
4452

4553
if (!RefreshTokenJti.TryParse(httpContext.User.FindFirstValue(JwtRegisteredClaimNames.Jti), out var jti))
4654
{
4755
logger.LogWarning("No valid 'jti' claim found in refresh token");
48-
return Result.Unauthorized("Invalid refresh token.");
56+
return Result.Unauthorized(InvalidRefreshTokenMessage, responseHeaders: invalidTokenHeaders);
4957
}
5058

5159
if (!int.TryParse(httpContext.User.FindFirstValue("ver"), out var refreshTokenVersion))
5260
{
5361
logger.LogWarning("No valid 'ver' claim found in refresh token");
54-
return Result.Unauthorized("Invalid refresh token.");
62+
return Result.Unauthorized(InvalidRefreshTokenMessage, responseHeaders: invalidTokenHeaders);
5563
}
5664

5765
var expiresClaim = httpContext.User.FindFirstValue(JwtRegisteredClaimNames.Exp);
5866
if (expiresClaim is null)
5967
{
6068
logger.LogWarning("No 'exp' claim found in refresh token");
61-
return Result.Unauthorized("Invalid refresh token.");
69+
return Result.Unauthorized(InvalidRefreshTokenMessage, responseHeaders: invalidTokenHeaders);
6270
}
6371

6472
if (!long.TryParse(expiresClaim, out var expiresUnixSeconds))
6573
{
6674
logger.LogWarning("Invalid 'exp' claim format in refresh token");
67-
return Result.Unauthorized("Invalid refresh token.");
75+
return Result.Unauthorized(InvalidRefreshTokenMessage, responseHeaders: invalidTokenHeaders);
6876
}
6977

7078
var refreshTokenExpires = DateTimeOffset.FromUnixTimeSeconds(expiresUnixSeconds);
@@ -74,19 +82,23 @@ public async Task<Result> Handle(RefreshAuthenticationTokensCommand command, Can
7482
if (session is null)
7583
{
7684
logger.LogWarning("No session found for session id '{SessionId}'", sessionId);
77-
return Result.Unauthorized("Invalid refresh token.");
85+
return Result.Unauthorized(InvalidRefreshTokenMessage, responseHeaders: invalidTokenHeaders);
7886
}
7987

8088
if (session.IsRevoked)
8189
{
82-
logger.LogWarning("Session '{SessionId}' has been revoked", session.Id);
83-
return Result.Unauthorized("Session has been revoked.");
90+
logger.LogWarning("Session '{SessionId}' has been revoked with reason '{RevokedReason}'", session.Id, session.RevokedReason);
91+
var unauthorizedHeaders = new Dictionary<string, string>
92+
{
93+
{ AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey, session.RevokedReason?.ToString() ?? nameof(UnauthorizedReason.Revoked) }
94+
};
95+
return Result.Unauthorized("Session has been revoked.", responseHeaders: unauthorizedHeaders);
8496
}
8597

8698
if (session.UserId != userId)
8799
{
88100
logger.LogWarning("Session user id '{SessionUserId}' does not match token user id '{TokenUserId}'", session.UserId, userId);
89-
return Result.Unauthorized("Invalid refresh token.");
101+
return Result.Unauthorized(InvalidRefreshTokenMessage, responseHeaders: invalidTokenHeaders);
90102
}
91103

92104
if (!session.IsRefreshTokenValid(jti, refreshTokenVersion, now))
@@ -95,17 +107,23 @@ public async Task<Result> Handle(RefreshAuthenticationTokensCommand command, Can
95107
"Replay attack detected for session '{SessionId}'. Token JTI '{TokenJti}', current JTI '{CurrentJti}'. Token version '{TokenVersion}', current version '{CurrentVersion}'",
96108
session.Id, jti, session.RefreshTokenJti, refreshTokenVersion, session.RefreshTokenVersion
97109
);
98-
session.Revoke(now, SessionRevokedReason.ReplayAttackDetected);
99-
sessionRepository.Update(session);
110+
111+
// Atomic revocation - only one concurrent request succeeds, but all return ReplayAttackDetected
112+
await sessionRepository.TryRevokeForReplayUnfilteredAsync(sessionId, now, cancellationToken);
113+
100114
events.CollectEvent(new SessionReplayDetected(session.Id, refreshTokenVersion, session.RefreshTokenVersion));
101-
return Result.Unauthorized("Invalid refresh token. Session has been revoked due to potential replay attack.", true);
115+
var unauthorizedHeaders = new Dictionary<string, string>
116+
{
117+
{ AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey, nameof(UnauthorizedReason.ReplayAttackDetected) }
118+
};
119+
return Result.Unauthorized("Invalid refresh token. Session has been revoked due to potential replay attack.", true, unauthorizedHeaders);
102120
}
103121

104122
var user = await userRepository.GetByIdAsync(userId, cancellationToken);
105123
if (user is null)
106124
{
107125
logger.LogWarning("No user found with user id '{UserId}'", userId);
108-
return Result.Unauthorized($"No user found with user id '{userId}'.");
126+
return Result.Unauthorized($"No user found with user id '{userId}'.", responseHeaders: invalidTokenHeaders);
109127
}
110128

111129
RefreshTokenJti tokenJti;

application/account-management/Core/Features/Authentication/Domain/SessionRepository.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ public interface ISessionRepository : ICrudRepository<Session, SessionId>
2828
/// Returns false if another concurrent request already refreshed the session.
2929
/// </summary>
3030
Task<bool> TryRefreshAsync(SessionId sessionId, RefreshTokenJti currentJti, int currentVersion, RefreshTokenJti newJti, DateTimeOffset now, CancellationToken cancellationToken);
31+
32+
/// <summary>
33+
/// Attempts to revoke the session for a replay attack without applying tenant query filters.
34+
/// Uses atomic update to handle concurrent requests - only one will succeed, but all callers
35+
/// can safely return ReplayAttackDetected since the session will be revoked either way.
36+
/// This method should only be used during token refresh where tenant context comes from the token claims.
37+
/// </summary>
38+
Task<bool> TryRevokeForReplayUnfilteredAsync(SessionId sessionId, DateTimeOffset now, CancellationToken cancellationToken);
3139
}
3240

3341
public sealed class SessionRepository(AccountManagementDbContext accountManagementDbContext)
@@ -75,6 +83,21 @@ UPDATE Sessions
7583
return rowsAffected == 1;
7684
}
7785

86+
public async Task<bool> TryRevokeForReplayUnfilteredAsync(SessionId sessionId, DateTimeOffset now, CancellationToken cancellationToken)
87+
{
88+
var rowsAffected = await DbSet
89+
.IgnoreQueryFilters()
90+
.Where(s => s.Id == sessionId && s.RevokedAt == null)
91+
.ExecuteUpdateAsync(s => s
92+
.SetProperty(x => x.RevokedAt, now)
93+
.SetProperty(x => x.RevokedReason, SessionRevokedReason.ReplayAttackDetected)
94+
.SetProperty(x => x.ModifiedAt, now),
95+
cancellationToken
96+
);
97+
98+
return rowsAffected == 1;
99+
}
100+
78101
public async Task<Session[]> GetActiveSessionsForUserAsync(UserId userId, CancellationToken cancellationToken)
79102
{
80103
var sessions = await DbSet

application/account-management/Core/Features/Authentication/Domain/SessionTypes.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ public enum DeviceType
1212
Tablet
1313
}
1414

15+
/// <summary>
16+
/// Represents why a session was revoked. This is a domain concept stored in the Session aggregate.
17+
/// For HTTP header reasons (which include additional cases like SessionNotFound), see
18+
/// <see cref="SharedKernel.Authentication.UnauthorizedReason" />.
19+
/// </summary>
1520
[PublicAPI]
1621
[JsonConverter(typeof(JsonStringEnumConverter))]
1722
public enum SessionRevokedReason

application/account-management/Tests/Authentication/RefreshAuthenticationTokensTests.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ public async Task RefreshAuthenticationTokens_WhenReplayAttackDetected_ShouldRev
8484

8585
// Assert
8686
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
87+
response.Headers.Should().ContainKey("x-unauthorized-reason");
88+
response.Headers.GetValues("x-unauthorized-reason").Single().Should().Be("ReplayAttackDetected");
8789

8890
object[] parameters = [new { id = sessionId.ToString() }];
8991
Connection.ExecuteScalar<string>("SELECT RevokedAt FROM Sessions WHERE Id = @id", parameters).Should().NotBeNull();
@@ -110,6 +112,8 @@ public async Task RefreshAuthenticationTokens_WhenSessionRevoked_ShouldReturnUna
110112

111113
// Assert
112114
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
115+
response.Headers.Should().ContainKey("x-unauthorized-reason");
116+
response.Headers.GetValues("x-unauthorized-reason").Single().Should().Be("Revoked");
113117
TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty();
114118
}
115119

@@ -128,6 +132,8 @@ public async Task RefreshAuthenticationTokens_WhenSessionNotFound_ShouldReturnUn
128132

129133
// Assert
130134
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
135+
response.Headers.Should().ContainKey("x-unauthorized-reason");
136+
response.Headers.GetValues("x-unauthorized-reason").Single().Should().Be("SessionNotFound");
131137
TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty();
132138
}
133139

0 commit comments

Comments
 (0)