diff --git a/src/Open.IdentityServer/src/Extensions/TokenExtensions.cs b/src/Open.IdentityServer/src/Extensions/TokenExtensions.cs
index 0826a47a9..8d5083735 100644
--- a/src/Open.IdentityServer/src/Extensions/TokenExtensions.cs
+++ b/src/Open.IdentityServer/src/Extensions/TokenExtensions.cs
@@ -32,12 +32,13 @@ public static class TokenExtensions
/// Thrown when a JSON-valued claim cannot be parsed, two conflicting JSON claims exist for the same key, or an unsupported JSON value type is encountered.
public static JwtPayload CreateJwtPayload(this Token token, TimeProvider clock, IdentityServerOptions options, ILogger logger)
{
+ var now = clock.GetUtcNow();
var payload = new JwtPayload(
token.Issuer,
null,
null,
- clock.GetUtcNow().UtcDateTime,
- clock.GetUtcNow().UtcDateTime.AddSeconds(token.Lifetime));
+ now.UtcDateTime,
+ now.UtcDateTime.AddSeconds(token.Lifetime));
foreach (var aud in token.Audiences)
{
diff --git a/src/Open.IdentityServer/src/Services/Default/DefaultTokenService.cs b/src/Open.IdentityServer/src/Services/Default/DefaultTokenService.cs
index 2b51f7b87..efbb33293 100644
--- a/src/Open.IdentityServer/src/Services/Default/DefaultTokenService.cs
+++ b/src/Open.IdentityServer/src/Services/Default/DefaultTokenService.cs
@@ -135,8 +135,9 @@ public virtual async Task CreateIdentityTokenAsync(TokenCreationRequest r
claims.Add(new Claim(JwtClaimTypes.Nonce, request.Nonce));
}
+ var now = Clock.GetUtcNow();
// add iat claim
- claims.Add(new Claim(JwtClaimTypes.IssuedAt, Clock.GetUtcNow().ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64));
+ claims.Add(new Claim(JwtClaimTypes.IssuedAt, now.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64));
// add at_hash claim
if (request.AccessTokenToHash.IsPresent())
@@ -172,7 +173,7 @@ public virtual async Task CreateIdentityTokenAsync(TokenCreationRequest r
var token = new Token(OidcConstants.TokenTypes.IdentityToken)
{
- CreationTime = Clock.GetUtcNow().UtcDateTime,
+ CreationTime = now.UtcDateTime,
Audiences = { request.ValidatedRequest.Client.ClientId },
Issuer = issuer,
Lifetime = request.ValidatedRequest.Client.IdentityTokenLifetime,
@@ -218,14 +219,15 @@ public virtual async Task CreateAccessTokenAsync(TokenCreationRequest req
claims.Add(new Claim(JwtClaimTypes.SessionId, request.ValidatedRequest.SessionId));
}
+ var now = Clock.GetUtcNow();
// iat claim as required by JWT profile
- claims.Add(new Claim(JwtClaimTypes.IssuedAt, Clock.GetUtcNow().ToUnixTimeSeconds().ToString(),
+ claims.Add(new Claim(JwtClaimTypes.IssuedAt, now.ToUnixTimeSeconds().ToString(),
ClaimValueTypes.Integer64));
var issuer = ContextAccessor.HttpContext.GetIdentityServerIssuerUri();
var token = new Token(OidcConstants.TokenTypes.AccessToken)
{
- CreationTime = Clock.GetUtcNow().UtcDateTime,
+ CreationTime = now.UtcDateTime,
Issuer = issuer,
Lifetime = request.ValidatedRequest.AccessTokenLifetime,
Claims = claims.Distinct(new ClaimComparer()).ToList(),
diff --git a/src/Open.IdentityServer/src/Services/Default/DistributedDeviceFlowThrottlingService.cs b/src/Open.IdentityServer/src/Services/Default/DistributedDeviceFlowThrottlingService.cs
index b4d55176c..636fd8a97 100644
--- a/src/Open.IdentityServer/src/Services/Default/DistributedDeviceFlowThrottlingService.cs
+++ b/src/Open.IdentityServer/src/Services/Default/DistributedDeviceFlowThrottlingService.cs
@@ -61,14 +61,15 @@ public async Task ShouldSlowDown(string deviceCode, DeviceCode details)
using var trace = _telemetry.Trace(TelemetryConstants.TraceCategories.Services, this);
var key = KeyPrefix + deviceCode;
- var options = new DistributedCacheEntryOptions {AbsoluteExpiration = _clock.GetUtcNow().AddSeconds(details.Lifetime)};
+ var now = _clock.GetUtcNow();
+ var options = new DistributedCacheEntryOptions { AbsoluteExpiration = now.AddSeconds(details.Lifetime) };
var lastSeenAsString = await _cache.GetStringAsync(key);
// record new
if (lastSeenAsString == null)
{
- await _cache.SetStringAsync(key, _clock.GetUtcNow().ToString("O"), options);
+ await _cache.SetStringAsync(key, now.ToString("O"), options);
return false;
}
@@ -76,15 +77,15 @@ public async Task ShouldSlowDown(string deviceCode, DeviceCode details)
if (DateTime.TryParse(lastSeenAsString, out var lastSeen))
{
var client = await _clientStore.FindEnabledClientByIdAsync(details.ClientId);
- if (_clock.GetUtcNow() < lastSeen.AddSeconds(client?.PollingInterval ?? _options.DeviceFlow.Interval))
+ if (now < lastSeen.AddSeconds(client?.PollingInterval ?? _options.DeviceFlow.Interval))
{
- await _cache.SetStringAsync(key, _clock.GetUtcNow().ToString("O"), options);
+ await _cache.SetStringAsync(key, now.ToString("O"), options);
return true;
}
}
// store current and continue
- await _cache.SetStringAsync(key, _clock.GetUtcNow().ToString("O"), options);
+ await _cache.SetStringAsync(key, now.ToString("O"), options);
return false;
}
}
\ No newline at end of file
diff --git a/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs
index 560dc810e..84107c94b 100644
--- a/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs
+++ b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs
@@ -92,6 +92,7 @@ public async Task RenewAsync(string key, AuthenticationTicket ticket)
return;
}
+ var issued = ticket.Properties.IssuedUtc?.UtcDateTime ?? timeProvider.GetUtcNow().UtcDateTime;
// If the session id or subject id has changed, remove any previous grants and create a new session
if(existingSession.SessionId != ticket.Properties.GetSessionId() ||
existingSession.SubjectId != ticket.Principal.GetSubjectId())
@@ -104,7 +105,7 @@ await persistedGrantStore.RemoveAllAsync(new PersistedGrantFilter
Types = IdentityServerConstants.PersistedGrantTypes.PersistedGrantTokenTypes,
});
- existingSession.Created = ticket.Properties.IssuedUtc?.UtcDateTime ?? timeProvider.GetUtcNow().UtcDateTime;
+ existingSession.Created = issued;
}
logger.RenewingTicketInStore(key, ticket.Properties.ExpiresUtc);
@@ -113,7 +114,7 @@ await persistedGrantStore.RemoveAllAsync(new PersistedGrantFilter
existingSession.SubjectId = ticket.Principal.GetSubjectId();
existingSession.SessionId = ticket.Properties.GetSessionId();
existingSession.DisplayName = ticket.Principal.FindFirstValue(JwtClaimTypes.Name);
- existingSession.Renewed = ticket.Properties.IssuedUtc?.UtcDateTime ?? timeProvider.GetUtcNow().UtcDateTime;
+ existingSession.Renewed = issued;
existingSession.Expires = ticket.Properties.ExpiresUtc?.UtcDateTime;
existingSession.Data = ToProtectedDataString(ticket);
@@ -227,7 +228,8 @@ private async Task StoreNewSession(string key, Authentication
string? displayName = string.IsNullOrWhiteSpace(options.ServerSideSessions.UserDisplayNameClaimType)
? null
: ticket.Principal.FindFirstValue(options.ServerSideSessions.UserDisplayNameClaimType);
-
+
+ var issued = ticket.Properties.IssuedUtc?.UtcDateTime ?? timeProvider.GetUtcNow().UtcDateTime;
ServerSideSession serverSideSession = new ServerSideSession
{
Key = key,
@@ -235,8 +237,8 @@ private async Task StoreNewSession(string key, Authentication
SubjectId = ticket.Principal.GetSubjectId(),
SessionId = ticket.Properties.GetSessionId(),
DisplayName = displayName,
- Created = ticket.Properties.IssuedUtc?.UtcDateTime ?? timeProvider.GetUtcNow().UtcDateTime,
- Renewed = ticket.Properties.IssuedUtc?.UtcDateTime ?? timeProvider.GetUtcNow().UtcDateTime,
+ Created = issued,
+ Renewed = issued,
Expires = ticket.Properties.ExpiresUtc?.UtcDateTime,
Data = ToProtectedDataString(ticket),
};
diff --git a/src/Open.IdentityServer/src/Validation/Default/SecretValidator.cs b/src/Open.IdentityServer/src/Validation/Default/SecretValidator.cs
index 90a129de4..a42a50f0c 100644
--- a/src/Open.IdentityServer/src/Validation/Default/SecretValidator.cs
+++ b/src/Open.IdentityServer/src/Validation/Default/SecretValidator.cs
@@ -46,14 +46,15 @@ public async Task ValidateAsync(IEnumerable secr
{
var secretsArray = secrets as Secret[] ?? secrets.ToArray();
- var expiredSecrets = secretsArray.Where(s => s.Expiration.HasExpired(_clock.GetUtcNow().UtcDateTime)).ToList();
+ var now = _clock.GetUtcNow();
+ var expiredSecrets = secretsArray.Where(s => s.Expiration.HasExpired(now.UtcDateTime)).ToList();
if (expiredSecrets.Any())
{
expiredSecrets.ForEach(
ex => _logger.LogInformation("Secret [{description}] is expired", ex.Description ?? "no description"));
}
- var currentSecrets = secretsArray.Where(s => !s.Expiration.HasExpired(_clock.GetUtcNow().UtcDateTime)).ToArray();
+ var currentSecrets = secretsArray.Where(s => !s.Expiration.HasExpired(now.UtcDateTime)).ToArray();
// see if a registered validator can validate the secret
foreach (var validator in _validators)
diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Extensions/JwtPayloadCreationTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Extensions/JwtPayloadCreationTests.cs
index dd53a2820..7180fa1ae 100644
--- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Extensions/JwtPayloadCreationTests.cs
+++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Extensions/JwtPayloadCreationTests.cs
@@ -99,4 +99,16 @@ public void Should_ignore_standard_validation_claims_in_token_claims()
payload.NotBefore.Should().Be(expectedNotBefore);
payload.Expiration.Should().Be(expectedExpiry);
}
+
+ [Fact]
+ public void Should_take_not_before_and_expiration_from_one_clock_read()
+ {
+ IdentityServerOptions options = new IdentityServerOptions();
+ // every read moves the clock on, so separate reads give different seconds
+ FakeTimeProvider fakeTimeProvider = new() { AutoAdvanceAmount = TimeSpan.FromSeconds(1) };
+
+ JwtPayload payload = _token.CreateJwtPayload(fakeTimeProvider, options, TestLogger.Create());
+
+ (payload.Expiration - payload.NotBefore).Should().Be(_token.Lifetime);
+ }
}
\ No newline at end of file
diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultTokenServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultTokenServiceTests.cs
index 41ef77747..dfb9c04dc 100644
--- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultTokenServiceTests.cs
+++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultTokenServiceTests.cs
@@ -2,6 +2,7 @@
// Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd.
// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
+using System;
using System.Linq;
using System.Threading.Tasks;
using AwesomeAssertions;
@@ -12,6 +13,7 @@
using Open.IdentityServer.Validation;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Time.Testing;
using Microsoft.IdentityModel.Tokens;
using Moq;
using Open.IdentityServer;
@@ -41,12 +43,17 @@ public DefaultTokenServiceTests()
svcs.AddSingleton(_options);
_httpContext.RequestServices = svcs.BuildServiceProvider();
- _subject = new DefaultTokenService(
+ _subject = CreateSubject(_mockSystemClock);
+ }
+
+ private DefaultTokenService CreateSubject(TimeProvider clock)
+ {
+ return new DefaultTokenService(
_mockClaimsService,
_mockReferenceTokenStore,
_mockTokenCreationService,
new HttpContextAccessor { HttpContext = _httpContext },
- _mockSystemClock,
+ clock,
_mockKeyMaterialService,
_options,
_telemetry.Object,
@@ -194,6 +201,51 @@ public async Task CreateAccessTokenAsync_when_session_should_include_sid()
result.Claims.SingleOrDefault(x => x.Type == JwtClaimTypes.SessionId).Value.Should().Be("123");
}
+ [Fact]
+ public async Task CreateAccessTokenAsync_WhenClockMovesBetweenReads_ShouldMatchIatToCreationTime()
+ {
+ // every read moves the clock on, so separate reads give different seconds
+ var subject = CreateSubject(new FakeTimeProvider { AutoAdvanceAmount = TimeSpan.FromSeconds(1) });
+
+ var request = new TokenCreationRequest
+ {
+ ValidatedResources = new ResourceValidationResult(),
+ ValidatedRequest = new ValidatedRequest()
+ {
+ Client = new Client { }
+ }
+ };
+
+ var result = await subject.CreateAccessTokenAsync(request);
+
+ result.Claims.Single(x => x.Type == JwtClaimTypes.IssuedAt).Value
+ .Should().Be(new DateTimeOffset(result.CreationTime).ToUnixTimeSeconds().ToString());
+ }
+
+ [Fact]
+ public async Task CreateIdentityTokenAsync_WhenClockMovesBetweenReads_ShouldMatchIatToCreationTime()
+ {
+ _mockKeyMaterialService.SigningCredentials.Add(
+ new SigningCredentials(CryptoHelper.CreateRsaSecurityKey(), "RS256"));
+
+ // every read moves the clock on, so separate reads give different seconds
+ var subject = CreateSubject(new FakeTimeProvider { AutoAdvanceAmount = TimeSpan.FromSeconds(1) });
+
+ var request = new TokenCreationRequest
+ {
+ ValidatedResources = new ResourceValidationResult(),
+ ValidatedRequest = new ValidatedRequest()
+ {
+ Client = new Client { }
+ }
+ };
+
+ var result = await subject.CreateIdentityTokenAsync(request);
+
+ result.Claims.Single(x => x.Type == JwtClaimTypes.IssuedAt).Value
+ .Should().Be(new DateTimeOffset(result.CreationTime).ToUnixTimeSeconds().ToString());
+ }
+
[Fact]
public async Task CreateIdentityTokenAsync_WhenCalled_ShouldInitiateTelemetryTrace()
{
diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DistributedDeviceFlowThrottlingServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DistributedDeviceFlowThrottlingServiceTests.cs
index beb743ca8..856c93631 100644
--- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DistributedDeviceFlowThrottlingServiceTests.cs
+++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DistributedDeviceFlowThrottlingServiceTests.cs
@@ -4,6 +4,7 @@
using AwesomeAssertions;
using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.Time.Testing;
using Moq;
using Open.IdentityServer.Configuration;
using Open.IdentityServer.Models;
@@ -62,13 +63,27 @@ public async Task First_Poll()
CheckCacheEntry(handle);
}
- private DistributedDeviceFlowThrottlingService CreateSubject()
+ [Fact]
+ public async Task First_Poll_Should_Expire_Cache_Entry_Lifetime_After_Stored_Time()
+ {
+ var handle = Guid.NewGuid().ToString();
+ // every read moves the clock on, so separate reads give different times
+ var service = CreateSubject(new FakeTimeProvider { AutoAdvanceAmount = TimeSpan.FromSeconds(1) });
+
+ await service.ShouldSlowDown(handle, deviceCode);
+
+ cache.Items.TryGetValue(CacheKey + handle, out var values).Should().BeTrue();
+ var storedTime = DateTimeOffset.Parse(Encoding.UTF8.GetString(values.Item1));
+ values.Item2.AbsoluteExpiration.Should().Be(storedTime.AddSeconds(deviceCode.Lifetime));
+ }
+
+ private DistributedDeviceFlowThrottlingService CreateSubject(TimeProvider clock = null)
{
return new DistributedDeviceFlowThrottlingService(
- cache,
+ cache,
_clientStore.Object,
- new StubClock {UtcNowFunc = () => testDate},
- options,
+ clock ?? new StubClock {UtcNowFunc = () => testDate},
+ options,
_telemetry.Object);
}
diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/ServerSessionTicketStoreTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/ServerSessionTicketStoreTests.cs
index 9612fdd80..a901251c9 100644
--- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/ServerSessionTicketStoreTests.cs
+++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/ServerSessionTicketStoreTests.cs
@@ -130,6 +130,28 @@ public async Task StoreAsync_WhenOptionalValuesNotProvided_ShouldUseCorrectDefau
dataProtector.ValidateProtectedData(actualPayload, expectedJson);
}
+ [Fact]
+ public async Task StoreAsync_WhenIssuedUtcNotProvided_ShouldSetCreatedAndRenewedFromOneClockRead()
+ {
+ AuthenticationTicket authenticationTicket = ServerSessionTestGenerators.GenerateAuthenticationTicket(
+ "FakeAuthScheme", Guid.NewGuid().ToString(), Guid.NewGuid().ToString());
+
+ ServerSideSession? createdSessionModel = null;
+ Mock.Get(serverServerSideSessionStore)
+ .Setup(x => x.CreateSession(It.IsAny()))
+ .Callback((session) => { createdSessionModel = session; });
+
+ // every read moves the clock on, so separate reads give different times
+ fakeTimeProvider.AutoAdvanceAmount = TimeSpan.FromSeconds(1);
+
+ ServerSessionTicketStore sut = CreateSut();
+
+ await sut.StoreAsync(authenticationTicket);
+
+ createdSessionModel.Should().NotBeNull();
+ createdSessionModel.Renewed.Should().Be(createdSessionModel.Created);
+ }
+
[Fact]
public async Task StoreAsync_WhenOptionalValuesProvided_ShouldUseThem()
{
@@ -335,6 +357,36 @@ public async Task RenewAsync_WhenSessionOverwritten_ShouldExpirePreviousGrants()
)));
}
+ [Fact]
+ public async Task RenewAsync_WhenSessionOverwrittenWithoutIssuedUtc_ShouldSetCreatedAndRenewedFromOneClockRead()
+ {
+ ServerSideSession existingSession = new ServerSideSession
+ {
+ Key = Guid.NewGuid().ToString(), Scheme = "AuthScheme",
+ SessionId = "session_id",
+ SubjectId = "subject_id",
+ Created = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc),
+ Renewed = new DateTime(2026, 1, 2, 12, 0, 0, DateTimeKind.Utc),
+ Data = "EXISTING_PROTECTED_DATA",
+ };
+
+ AuthenticationTicket authenticationTicket = ServerSessionTestGenerators.GenerateAuthenticationTicket(
+ "FakeAuthScheme", Guid.NewGuid().ToString(), Guid.NewGuid().ToString());
+
+ Mock.Get(serverServerSideSessionStore)
+ .Setup(x => x.GetSession(existingSession.Key))
+ .ReturnsAsync(existingSession);
+
+ // every read moves the clock on, so separate reads give different times
+ fakeTimeProvider.AutoAdvanceAmount = TimeSpan.FromSeconds(1);
+
+ ServerSessionTicketStore sut = CreateSut();
+
+ await sut.RenewAsync(existingSession.Key, authenticationTicket);
+
+ existingSession.Renewed.Should().Be(existingSession.Created);
+ }
+
[Fact]
public async Task RenewAsync_WhenOptionalValuesProvided_ShouldUseThem()
{
diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/Secrets/SecretValidation.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/Secrets/SecretValidation.cs
index aec58694a..52f5b0f45 100644
--- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/Secrets/SecretValidation.cs
+++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/Secrets/SecretValidation.cs
@@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
+using System;
using System.Threading.Tasks;
using AwesomeAssertions;
using Open.IdentityServer.UnitTests.Common;
@@ -11,6 +12,7 @@
using Open.IdentityServer.Stores;
using Open.IdentityServer.Validation;
using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Time.Testing;
using Xunit;
namespace Open.IdentityServer.UnitTests.Validation.Secrets;
@@ -137,6 +139,32 @@ public async Task Expired_Secret()
result.Success.Should().BeFalse();
}
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task Secret_Expiring_During_Validation_Should_Be_Checked_Against_One_Clock_Read()
+ {
+ var start = new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero);
+
+ // every read moves the clock on: the secret is current at the first read and expired at the second
+ var validator = new SecretValidator(
+ new FakeTimeProvider(start) { AutoAdvanceAmount = TimeSpan.FromSeconds(1) },
+ new[] { _hashedSecretValidator },
+ new Logger(new LoggerFactory()));
+
+ var secrets = new[] { new Secret("secret".Sha256()) { Expiration = start.UtcDateTime.AddMilliseconds(500) } };
+
+ var secret = new ParsedSecret
+ {
+ Id = "client",
+ Credential = "secret",
+ Type = IdentityServerConstants.ParsedSecretTypes.SharedSecret
+ };
+
+ var result = await validator.ValidateAsync(secrets, secret);
+
+ result.Success.Should().BeTrue();
+ }
+
[Fact]
[Trait("Category", Category)]
public async Task Invalid_Multiple_Secrets()