diff --git a/code-reviews/Security/findings.md b/code-reviews/Security/findings.md
index 4b0cf76..997025f 100644
--- a/code-reviews/Security/findings.md
+++ b/code-reviews/Security/findings.md
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-16 |
| Reviewer | claude-agent |
| Commit reviewed | `9c60592` |
-| Open findings | 8 |
+| Open findings | 4 |
## Summary
@@ -156,7 +156,7 @@ corrected to state the requirement. Regression tests
|--|--|
| Severity | Medium |
| Category | Correctness & logic bugs |
-| Status | Open |
+| Status | Resolved |
| Location | `src/ScadaLink.Security/LdapAuthService.cs:66`, `:138`, `:157-159` |
**Description**
@@ -176,7 +176,15 @@ consistently in both the search filter and the fallback DN. Update the XML doc t
**Resolution**
-_Unresolved._
+Resolved 2026-05-16 (commit `pending`). Confirmed: the search filter was hard-coded
+`(uid={username})` (both in `AuthenticateAsync` and `ResolveUserDnAsync`) while the
+fallback DN used `cn={username}` — the two auth modes were not interchangeable. Added
+a configurable `SecurityOptions.LdapUserIdAttribute` (default `uid`) used for both the
+search filter and the fallback DN via the new `BuildFallbackUserDn` helper, and
+corrected the `LdapServiceAccountDn` XML doc to reference `{LdapUserIdAttribute}`.
+Regression tests `BuildFallbackUserDn_UsesConfiguredUserIdAttribute`,
+`BuildFallbackUserDn_HonoursNonDefaultUserIdAttribute`,
+`SecurityOptions_LdapUserIdAttribute_DefaultsToUid`.
### Security-005 — DN injection in the no-service-account bind fallback
@@ -184,7 +192,7 @@ _Unresolved._
|--|--|
| Severity | Medium |
| Category | Security |
-| Status | Open |
+| Status | Resolved |
| Location | `src/ScadaLink.Security/LdapAuthService.cs:157-159` |
**Description**
@@ -207,7 +215,15 @@ raw DN from untrusted input is risky; restrict it or remove it.
**Resolution**
-_Unresolved._
+Resolved 2026-05-16 (commit `pending`). Confirmed: the fallback path interpolated
+the raw `username` straight into `cn={username},{LdapSearchBase}` with no DN escaping,
+and the `username.Contains('=')` shortcut let a caller supply an arbitrary bind DN.
+Added an RFC 4514 `EscapeLdapDn` helper (escapes `, + " \ < > ;`, leading/trailing
+space, leading `#`, NUL) applied in `BuildFallbackUserDn`, so a username such as
+`victim,ou=admins` can no longer alter the DN structure. The `Contains('=')` raw-DN
+shortcut was removed entirely — untrusted input no longer controls the bind identity.
+Regression tests `BuildFallbackUserDn_EscapesDnMetacharacters`,
+`EscapeLdapDn_EscapesAllRfc4514Specials`, `EscapeLdapDn_EscapesLeadingAndTrailingSpaces`.
### Security-006 — JWT validation disables issuer and audience checks
@@ -215,7 +231,7 @@ _Unresolved._
|--|--|
| Severity | Medium |
| Category | Security |
-| Status | Open |
+| Status | Resolved |
| Location | `src/ScadaLink.Security/JwtTokenService.cs:67-75`, `:56-59` |
**Description**
@@ -236,7 +252,14 @@ validation.
**Resolution**
-_Unresolved._
+Resolved 2026-05-16 (commit `pending`). Confirmed: `GenerateToken` set neither `iss`
+nor `aud` and `ValidateToken` had `ValidateIssuer = false`/`ValidateAudience = false`.
+`GenerateToken` now binds `JwtTokenService.TokenIssuer`/`TokenAudience`
+(both `"scadalink-central"`) into every token, and `ValidateToken` enables
+`ValidateIssuer`/`ValidateAudience` against those fixed values — a token signed with
+the shared key but a foreign issuer is now rejected. Regression tests
+`GenerateToken_SetsIssuerAndAudience`, `ValidateToken_RejectsTokenWithWrongIssuer`,
+`ValidateToken_AcceptsOwnIssuerAndAudience`.
### Security-007 — Idle-timeout claim is reset on every token refresh
@@ -244,7 +267,7 @@ _Unresolved._
|--|--|
| Severity | Medium |
| Category | Correctness & logic bugs |
-| Status | Open |
+| Status | Resolved |
| Location | `src/ScadaLink.Security/JwtTokenService.cs:40`, `:111-123` |
**Description**
@@ -269,7 +292,22 @@ path agree on the semantics.
**Resolution**
-_Unresolved._
+Resolved 2026-05-16 (commit `pending`). Confirmed: `RefreshToken` → `GenerateToken`
+unconditionally wrote `LastActivity = UtcNow`, so the idle clock reset on every
+refresh and the documented 30-minute idle timeout could never fire for a client that
+polls in the background. Implemented option (a) — the Security-side half of the
+documented "15-min sliding + 30-min idle" policy (the cross-module partner of
+CentralUI-005): `GenerateToken` now takes an optional `lastActivity` anchor;
+`RefreshToken` carries the **existing** `LastActivity` claim forward unchanged, and a
+new explicit `RecordActivity` method advances the anchor to now — to be called by the
+CentralUI request pipeline on genuine user interaction (not on a background refresh).
+`IsIdleTimedOut` is unchanged and now agrees with the refresh path. The remaining
+CentralUI-side wiring (calling `RecordActivity` from the middleware, setting
+`SlidingExpiration`/`ExpireTimeSpan`) stays tracked under CentralUI-005; this finding's
+Security-side defect — the reset-on-refresh bug — is fully fixed here. Regression tests
+`RefreshToken_PreservesOriginalLastActivityClaim`,
+`RefreshToken_DoesNotResetIdleTimeoutWhenUserIsActuallyIdle`,
+`RecordActivity_UpdatesLastActivityToNow`.
### Security-008 — N+1 query loading site-scope rules in `RoleMapper`
diff --git a/src/ScadaLink.Security/JwtTokenService.cs b/src/ScadaLink.Security/JwtTokenService.cs
index 14747b3..17ea864 100644
--- a/src/ScadaLink.Security/JwtTokenService.cs
+++ b/src/ScadaLink.Security/JwtTokenService.cs
@@ -18,6 +18,17 @@ public class JwtTokenService
public const string SiteIdClaimType = "SiteId";
public const string LastActivityClaimType = "LastActivity";
+ ///
+ /// Fixed issuer bound into every token and required on validation. Binding
+ /// issuer/audience is defence-in-depth: even though the HMAC key is shared only
+ /// between the two central nodes, accidental reuse of the same secret for an
+ /// unrelated internal token would otherwise be silently exploitable.
+ ///
+ public const string TokenIssuer = "scadalink-central";
+
+ /// Fixed audience bound into every token and required on validation.
+ public const string TokenAudience = "scadalink-central";
+
public JwtTokenService(IOptions options, ILogger logger)
{
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
@@ -37,11 +48,19 @@ public class JwtTokenService
}
}
+ ///
+ /// Issues a fresh JWT. sets the idle-timeout
+ /// anchor; when omitted (a brand-new login) it defaults to now. On a token
+ /// refresh the caller MUST pass the existing anchor forward so the idle window
+ /// continues to be measured from the user's last genuine activity rather than
+ /// from token issuance time.
+ ///
public string GenerateToken(
string displayName,
string username,
IReadOnlyList roles,
- IReadOnlyList? permittedSiteIds)
+ IReadOnlyList? permittedSiteIds,
+ DateTimeOffset? lastActivity = null)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
@@ -50,7 +69,7 @@ public class JwtTokenService
{
new(DisplayNameClaimType, displayName),
new(UsernameClaimType, username),
- new(LastActivityClaimType, DateTimeOffset.UtcNow.ToString("o"))
+ new(LastActivityClaimType, (lastActivity ?? DateTimeOffset.UtcNow).ToString("o"))
};
foreach (var role in roles)
@@ -67,6 +86,8 @@ public class JwtTokenService
}
var token = new JwtSecurityToken(
+ issuer: TokenIssuer,
+ audience: TokenAudience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(_options.JwtExpiryMinutes),
signingCredentials: credentials);
@@ -79,8 +100,10 @@ public class JwtTokenService
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey));
var validationParameters = new TokenValidationParameters
{
- ValidateIssuer = false,
- ValidateAudience = false,
+ ValidateIssuer = true,
+ ValidIssuer = TokenIssuer,
+ ValidateAudience = true,
+ ValidAudience = TokenAudience,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = key,
@@ -121,6 +144,15 @@ public class JwtTokenService
return (DateTimeOffset.UtcNow - lastActivity).TotalMinutes > _options.IdleTimeoutMinutes;
}
+ ///
+ /// Issues a fresh token (new expiry, re-queried roles) while preserving the
+ /// existing anchor. A refresh is itself
+ /// triggered by a request, but it must not be treated as user activity — the
+ /// idle window must keep being measured from the user's last genuine interaction,
+ /// otherwise the documented 30-minute idle timeout could never fire for a client
+ /// that polls in the background. Call to advance the
+ /// anchor when handling a genuine user request.
+ ///
public string? RefreshToken(ClaimsPrincipal currentPrincipal, IReadOnlyList currentRoles, IReadOnlyList? permittedSiteIds)
{
var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value;
@@ -132,6 +164,36 @@ public class JwtTokenService
return null;
}
- return GenerateToken(displayName, username, currentRoles, permittedSiteIds);
+ return GenerateToken(displayName, username, currentRoles, permittedSiteIds,
+ ReadLastActivity(currentPrincipal));
+ }
+
+ ///
+ /// Issues a fresh token whose anchor is
+ /// advanced to now. This is the explicit "user did something" path — distinct
+ /// from — to be called by the request pipeline when
+ /// handling a genuine user interaction.
+ ///
+ public string? RecordActivity(ClaimsPrincipal currentPrincipal, IReadOnlyList currentRoles, IReadOnlyList? permittedSiteIds)
+ {
+ var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value;
+ var username = currentPrincipal.FindFirst(UsernameClaimType)?.Value;
+
+ if (displayName == null || username == null)
+ {
+ _logger.LogWarning("Cannot record activity: missing DisplayName or Username claims");
+ return null;
+ }
+
+ return GenerateToken(displayName, username, currentRoles, permittedSiteIds,
+ DateTimeOffset.UtcNow);
+ }
+
+ private static DateTimeOffset? ReadLastActivity(ClaimsPrincipal principal)
+ {
+ var claim = principal.FindFirst(LastActivityClaimType);
+ return claim != null && DateTimeOffset.TryParse(claim.Value, out var value)
+ ? value
+ : null;
}
}
diff --git a/src/ScadaLink.Security/LdapAuthService.cs b/src/ScadaLink.Security/LdapAuthService.cs
index 2ddffb7..62082fc 100644
--- a/src/ScadaLink.Security/LdapAuthService.cs
+++ b/src/ScadaLink.Security/LdapAuthService.cs
@@ -71,7 +71,7 @@ public class LdapAuthService
try
{
- var searchFilter = $"(uid={EscapeLdapFilter(username)})";
+ var searchFilter = $"({_options.LdapUserIdAttribute}={EscapeLdapFilter(username)})";
var searchResults = await Task.Run(() =>
connection.Search(
_options.LdapSearchBase,
@@ -133,17 +133,13 @@ public class LdapAuthService
///
private async Task ResolveUserDnAsync(LdapConnection connection, string username, CancellationToken ct)
{
- // If username already looks like a DN, use it as-is
- if (username.Contains('='))
- return username;
-
// If a service account is configured, search for the user's actual DN
if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn))
{
await Task.Run(() =>
connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), ct);
- var searchFilter = $"(uid={EscapeLdapFilter(username)})";
+ var searchFilter = $"({_options.LdapUserIdAttribute}={EscapeLdapFilter(username)})";
var searchResults = await Task.Run(() =>
connection.Search(
_options.LdapSearchBase,
@@ -158,13 +154,68 @@ public class LdapAuthService
return entry.Dn;
}
- throw new LdapException("User not found", LdapException.NoSuchObject, $"No entry found for uid={username}");
+ throw new LdapException("User not found", LdapException.NoSuchObject,
+ $"No entry found for {_options.LdapUserIdAttribute}={username}");
}
- // Fallback: construct DN directly
- return string.IsNullOrWhiteSpace(_options.LdapSearchBase)
- ? $"cn={username}"
- : $"cn={username},{_options.LdapSearchBase}";
+ // Fallback: construct the bind DN directly from the configured user-id
+ // attribute. The username is RFC 4514 DN-escaped so it cannot alter the
+ // DN structure (Security-005). The previous Contains('=') shortcut that
+ // accepted a raw caller-supplied DN has been removed — accepting an
+ // arbitrary DN from untrusted input let a client choose the bind identity.
+ return BuildFallbackUserDn(username, _options.LdapSearchBase, _options.LdapUserIdAttribute);
+ }
+
+ ///
+ /// Builds the no-service-account fallback bind DN as
+ /// {userIdAttribute}={escaped-username}[,{searchBase}]. The username is
+ /// escaped per RFC 4514 so DN metacharacters in untrusted input cannot inject
+ /// additional RDN components or change the bind identity.
+ ///
+ public static string BuildFallbackUserDn(string username, string searchBase, string userIdAttribute)
+ {
+ var rdn = $"{userIdAttribute}={EscapeLdapDn(username)}";
+ return string.IsNullOrWhiteSpace(searchBase) ? rdn : $"{rdn},{searchBase}";
+ }
+
+ ///
+ /// Escapes a string for use as an RFC 4514 DN attribute value: the special
+ /// characters , + " \ < > ; are backslash-escaped, as are a leading
+ /// or trailing space and a leading #.
+ ///
+ public static string EscapeLdapDn(string input)
+ {
+ if (string.IsNullOrEmpty(input))
+ return input;
+
+ var sb = new System.Text.StringBuilder(input.Length + 8);
+ for (var i = 0; i < input.Length; i++)
+ {
+ var c = input[i];
+ var isEdgeSpace = c == ' ' && (i == 0 || i == input.Length - 1);
+ var isLeadingHash = c == '#' && i == 0;
+ switch (c)
+ {
+ case ',':
+ case '+':
+ case '"':
+ case '\\':
+ case '<':
+ case '>':
+ case ';':
+ sb.Append('\\').Append(c);
+ break;
+ case '\0':
+ sb.Append("\\00");
+ break;
+ default:
+ if (isEdgeSpace || isLeadingHash)
+ sb.Append('\\');
+ sb.Append(c);
+ break;
+ }
+ }
+ return sb.ToString();
}
private static string EscapeLdapFilter(string input)
diff --git a/src/ScadaLink.Security/SecurityOptions.cs b/src/ScadaLink.Security/SecurityOptions.cs
index e6aac5f..909d23d 100644
--- a/src/ScadaLink.Security/SecurityOptions.cs
+++ b/src/ScadaLink.Security/SecurityOptions.cs
@@ -37,10 +37,19 @@ public class SecurityOptions
///
/// Service account DN for LDAP user searches (e.g., "cn=admin,dc=example,dc=com").
/// Required for search-then-bind authentication. If empty, direct bind with
- /// cn={username},{LdapSearchBase} is attempted instead.
+ /// {LdapUserIdAttribute}={username},{LdapSearchBase} is attempted instead.
///
public string LdapServiceAccountDn { get; set; } = string.Empty;
+ ///
+ /// LDAP attribute that identifies a user. Used both for the search-then-bind
+ /// filter (({LdapUserIdAttribute}={username})) and for constructing the
+ /// fallback bind DN when no service account is configured, so the two
+ /// authentication modes are interchangeable. Common values: uid (OpenLDAP),
+ /// sAMAccountName (Active Directory).
+ ///
+ public string LdapUserIdAttribute { get; set; } = "uid";
+
///
/// Service account password for LDAP user searches.
///
diff --git a/tests/ScadaLink.Security.Tests/UnitTest1.cs b/tests/ScadaLink.Security.Tests/UnitTest1.cs
index 23c53a3..6f5c58c 100644
--- a/tests/ScadaLink.Security.Tests/UnitTest1.cs
+++ b/tests/ScadaLink.Security.Tests/UnitTest1.cs
@@ -475,6 +475,203 @@ public class SecurityReviewRegressionTests
#endregion
+#region Code Review Regression Tests — Security-004/005/006/007
+
+///
+/// Regression tests for Security-004 (uid/cn attribute mismatch between search filter
+/// and fallback DN), Security-005 (DN injection in the no-service-account fallback),
+/// Security-006 (JWT issuer/audience checks disabled), and Security-007 (idle-timeout
+/// claim reset on every token refresh).
+///
+public class SecurityReviewRegressionTests2
+{
+ private static SecurityOptions JwtOptions() => new()
+ {
+ JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
+ JwtExpiryMinutes = 15,
+ IdleTimeoutMinutes = 30,
+ JwtRefreshThresholdMinutes = 5
+ };
+
+ private static JwtTokenService CreateJwtService(SecurityOptions? options = null) =>
+ new(Options.Create(options ?? JwtOptions()), NullLogger.Instance);
+
+ // --- Security-004: search filter and fallback DN must use the same attribute ---
+
+ [Fact]
+ public void BuildFallbackUserDn_UsesConfiguredUserIdAttribute()
+ {
+ // The default user-id attribute is "uid"; the fallback DN must use it,
+ // not a hard-coded "cn", so search-then-bind and direct-bind are interchangeable.
+ var dn = LdapAuthService.BuildFallbackUserDn("alice", "dc=example,dc=com", "uid");
+ Assert.Equal("uid=alice,dc=example,dc=com", dn);
+ }
+
+ [Fact]
+ public void BuildFallbackUserDn_HonoursNonDefaultUserIdAttribute()
+ {
+ var dn = LdapAuthService.BuildFallbackUserDn("alice", "dc=example,dc=com", "sAMAccountName");
+ Assert.Equal("sAMAccountName=alice,dc=example,dc=com", dn);
+ }
+
+ [Fact]
+ public void SecurityOptions_LdapUserIdAttribute_DefaultsToUid()
+ {
+ Assert.Equal("uid", new SecurityOptions().LdapUserIdAttribute);
+ }
+
+ // --- Security-005: DN-component escaping must be applied to the username ---
+
+ [Fact]
+ public void BuildFallbackUserDn_EscapesDnMetacharacters()
+ {
+ // A hostile username must not be able to alter the DN structure: the comma
+ // that would otherwise start a new RDN ("ou=admins") must be escaped so the
+ // whole string remains a single RDN value.
+ var dn = LdapAuthService.BuildFallbackUserDn("victim,ou=admins", "dc=example,dc=com", "uid");
+ Assert.Equal(@"uid=victim\,ou=admins,dc=example,dc=com", dn);
+ // The comma from the username is backslash-escaped, so it does not act as an
+ // RDN separator: the only unescaped comma is the one joining RDN and base DN.
+ Assert.Contains(@"victim\,ou=admins", dn);
+ }
+
+ [Fact]
+ public void EscapeLdapDn_EscapesAllRfc4514Specials()
+ {
+ var escaped = LdapAuthService.EscapeLdapDn("a,b+c\"d\\eg;h");
+ Assert.Equal(@"a\,b\+c\""d\\e\g\;h", escaped);
+ }
+
+ [Fact]
+ public void EscapeLdapDn_EscapesLeadingAndTrailingSpaces()
+ {
+ Assert.Equal(@"\ x \ ", LdapAuthService.EscapeLdapDn(" x "));
+ }
+
+ // --- Security-006: JWT issuer/audience must be bound and validated ---
+
+ [Fact]
+ public void GenerateToken_SetsIssuerAndAudience()
+ {
+ var service = CreateJwtService();
+ var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);
+
+ var jwt = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().ReadJwtToken(token);
+ Assert.Equal(JwtTokenService.TokenIssuer, jwt.Issuer);
+ Assert.Contains(JwtTokenService.TokenAudience, jwt.Audiences);
+ }
+
+ [Fact]
+ public void ValidateToken_RejectsTokenWithWrongIssuer()
+ {
+ // A token signed with the same key but a foreign issuer must be rejected.
+ var options = JwtOptions();
+ var key = new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey(
+ System.Text.Encoding.UTF8.GetBytes(options.JwtSigningKey));
+ var creds = new Microsoft.IdentityModel.Tokens.SigningCredentials(
+ key, Microsoft.IdentityModel.Tokens.SecurityAlgorithms.HmacSha256);
+ var foreign = new System.IdentityModel.Tokens.Jwt.JwtSecurityToken(
+ issuer: "some-other-system",
+ audience: JwtTokenService.TokenAudience,
+ claims: new[] { new Claim(JwtTokenService.UsernameClaimType, "user") },
+ expires: DateTime.UtcNow.AddMinutes(10),
+ signingCredentials: creds);
+ var foreignToken = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().WriteToken(foreign);
+
+ var service = CreateJwtService(options);
+ Assert.Null(service.ValidateToken(foreignToken));
+ }
+
+ [Fact]
+ public void ValidateToken_AcceptsOwnIssuerAndAudience()
+ {
+ var service = CreateJwtService();
+ var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);
+ Assert.NotNull(service.ValidateToken(token));
+ }
+
+ // --- Security-007: refresh must preserve the original LastActivity timestamp ---
+
+ [Fact]
+ public void RefreshToken_PreservesOriginalLastActivityClaim()
+ {
+ var service = CreateJwtService();
+
+ // Mint a token whose LastActivity is 20 minutes in the past (still inside the
+ // 30-minute idle window). Refresh must NOT move it forward to "now".
+ var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-20);
+ var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
+ {
+ new Claim(JwtTokenService.DisplayNameClaimType, "User"),
+ new Claim(JwtTokenService.UsernameClaimType, "user"),
+ new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
+ }, "test"));
+
+ var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null);
+ Assert.NotNull(refreshed);
+
+ var refreshedPrincipal = service.ValidateToken(refreshed!);
+ Assert.NotNull(refreshedPrincipal);
+ var claim = refreshedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType);
+ Assert.NotNull(claim);
+ Assert.True(DateTimeOffset.TryParse(claim!.Value, out var carried));
+ // The carried timestamp must equal the original, not "now".
+ Assert.True(Math.Abs((carried - staleActivity).TotalSeconds) < 2,
+ $"LastActivity was reset on refresh: expected ~{staleActivity:o}, got {carried:o}");
+ }
+
+ [Fact]
+ public void RefreshToken_DoesNotResetIdleTimeoutWhenUserIsActuallyIdle()
+ {
+ // A user idle for 25 of the 30-minute window: a refresh fired by some background
+ // request must not make IsIdleTimedOut flip back to false forever.
+ var service = CreateJwtService();
+ var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-25);
+ var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
+ {
+ new Claim(JwtTokenService.DisplayNameClaimType, "User"),
+ new Claim(JwtTokenService.UsernameClaimType, "user"),
+ new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
+ }, "test"));
+
+ var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null);
+ var refreshedPrincipal = service.ValidateToken(refreshed!);
+
+ // Still 25 min idle after refresh — not reset to 0.
+ Assert.False(service.IsIdleTimedOut(refreshedPrincipal!)); // 25 < 30, still valid
+ var claim = refreshedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType);
+ Assert.True(DateTimeOffset.TryParse(claim!.Value, out var carried));
+ Assert.True((DateTimeOffset.UtcNow - carried).TotalMinutes > 20,
+ "Refresh wrongly reset the idle clock to ~now");
+ }
+
+ [Fact]
+ public void RecordActivity_UpdatesLastActivityToNow()
+ {
+ // Genuine user activity (a real request) — distinct from a token refresh —
+ // updates LastActivity to the current time.
+ var service = CreateJwtService();
+ var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-20);
+ var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
+ {
+ new Claim(JwtTokenService.DisplayNameClaimType, "User"),
+ new Claim(JwtTokenService.UsernameClaimType, "user"),
+ new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
+ }, "test"));
+
+ var touched = service.RecordActivity(principal, new[] { "Admin" }, null);
+ Assert.NotNull(touched);
+
+ var touchedPrincipal = service.ValidateToken(touched!);
+ var claim = touchedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType);
+ Assert.True(DateTimeOffset.TryParse(claim!.Value, out var updated));
+ Assert.True((DateTimeOffset.UtcNow - updated).TotalSeconds < 5,
+ "RecordActivity should set LastActivity to ~now");
+ }
+}
+
+#endregion
+
#region WP-9: Authorization Policy Tests
public class AuthorizationPolicyTests