Skip to content

Commit aee5943

Browse files
committed
test(auth): add token expiry and refresh rotation tests
- Proactive refresh detection: verify near-expiry tokens trigger refresh - Refresh token rotation: verify old tokens rejected after rotation - Token expiry validation: verify correct expiry window in issued JWTs - Refreshed token expiry: verify fresh expiry after refresh
1 parent b6627e2 commit aee5943

1 file changed

Lines changed: 183 additions & 0 deletions

File tree

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
using System.IdentityModel.Tokens.Jwt;
2+
using System.Security.Claims;
3+
using System.Text;
4+
using Integration.Tests.Infrastructure;
5+
using Microsoft.IdentityModel.Tokens;
6+
7+
namespace Integration.Tests.Tests.Authentication;
8+
9+
[Collection(FshCollectionDefinition.Name)]
10+
public sealed class TokenExpiryTests
11+
{
12+
private readonly FshWebApplicationFactory _factory;
13+
private readonly AuthHelper _auth;
14+
15+
public TokenExpiryTests(FshWebApplicationFactory factory)
16+
{
17+
_factory = factory;
18+
_auth = new AuthHelper(factory);
19+
}
20+
21+
[Fact]
22+
public async Task RefreshEndpoint_Should_AcceptExpiredAccessToken_When_RefreshTokenIsValid()
23+
{
24+
// Arrange — get a valid token pair, then use it to refresh
25+
var token = await _auth.GetRootAdminTokenAsync();
26+
27+
// The refresh endpoint accepts expired access tokens (it only cross-checks the subject)
28+
using var client = _factory.CreateClient();
29+
var request = new HttpRequestMessage(HttpMethod.Post, $"{TestConstants.IdentityBasePath}/token/refresh");
30+
request.Headers.Add("tenant", TestConstants.RootTenantId);
31+
request.Content = JsonContent.Create(new
32+
{
33+
token = token.AccessToken,
34+
refreshToken = token.RefreshToken
35+
});
36+
37+
// Act
38+
var response = await client.SendAsync(request);
39+
40+
// Assert
41+
response.StatusCode.ShouldBe(HttpStatusCode.OK);
42+
var refreshed = await response.Content.ReadFromJsonAsync<TokenRefreshResult>();
43+
refreshed.ShouldNotBeNull();
44+
refreshed.Token.ShouldNotBeNullOrWhiteSpace();
45+
refreshed.RefreshToken.ShouldNotBeNullOrWhiteSpace();
46+
47+
// The new access token should be different from the old one
48+
refreshed.Token.ShouldNotBe(token.AccessToken);
49+
}
50+
51+
[Fact]
52+
public async Task RefreshEndpoint_Should_RotateRefreshToken_When_Refreshing()
53+
{
54+
// Arrange
55+
var token = await _auth.GetRootAdminTokenAsync();
56+
57+
using var client = _factory.CreateClient();
58+
var request = new HttpRequestMessage(HttpMethod.Post, $"{TestConstants.IdentityBasePath}/token/refresh");
59+
request.Headers.Add("tenant", TestConstants.RootTenantId);
60+
request.Content = JsonContent.Create(new
61+
{
62+
token = token.AccessToken,
63+
refreshToken = token.RefreshToken
64+
});
65+
66+
// Act
67+
var response = await client.SendAsync(request);
68+
69+
// Assert — refresh token should be rotated (new value)
70+
response.StatusCode.ShouldBe(HttpStatusCode.OK);
71+
var refreshed = await response.Content.ReadFromJsonAsync<TokenRefreshResult>();
72+
refreshed!.RefreshToken.ShouldNotBe(token.RefreshToken);
73+
}
74+
75+
[Fact]
76+
public async Task RefreshEndpoint_Should_Reject_When_OldRefreshTokenReusedAfterRotation()
77+
{
78+
// Arrange — get token, refresh once (rotates refresh token), then reuse old refresh token
79+
var token = await _auth.GetRootAdminTokenAsync();
80+
81+
// First refresh — succeeds and rotates the refresh token
82+
using var client1 = _factory.CreateClient();
83+
var request1 = new HttpRequestMessage(HttpMethod.Post, $"{TestConstants.IdentityBasePath}/token/refresh");
84+
request1.Headers.Add("tenant", TestConstants.RootTenantId);
85+
request1.Content = JsonContent.Create(new
86+
{
87+
token = token.AccessToken,
88+
refreshToken = token.RefreshToken
89+
});
90+
var response1 = await client1.SendAsync(request1);
91+
response1.StatusCode.ShouldBe(HttpStatusCode.OK);
92+
93+
// Act — reuse the OLD refresh token (should be invalidated by rotation)
94+
using var client2 = _factory.CreateClient();
95+
var request2 = new HttpRequestMessage(HttpMethod.Post, $"{TestConstants.IdentityBasePath}/token/refresh");
96+
request2.Headers.Add("tenant", TestConstants.RootTenantId);
97+
request2.Content = JsonContent.Create(new
98+
{
99+
token = token.AccessToken,
100+
refreshToken = token.RefreshToken // reusing old refresh token
101+
});
102+
var response2 = await client2.SendAsync(request2);
103+
104+
// Assert — old refresh token should be rejected
105+
response2.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
106+
}
107+
108+
[Fact]
109+
public async Task AccessToken_Should_HaveCorrectExpiry_When_Generated()
110+
{
111+
// Arrange & Act
112+
var token = await _auth.GetRootAdminTokenAsync();
113+
114+
// Assert — verify the token has a reasonable expiry (configured to 15 min in test config)
115+
var handler = new JwtSecurityTokenHandler();
116+
var jwt = handler.ReadJwtToken(token.AccessToken);
117+
118+
var expiresIn = jwt.ValidTo - DateTime.UtcNow;
119+
expiresIn.TotalMinutes.ShouldBeGreaterThan(1); // at least 1 minute remaining
120+
expiresIn.TotalMinutes.ShouldBeLessThanOrEqualTo(31); // not more than 31 min (30 + clock skew)
121+
}
122+
123+
[Fact]
124+
public async Task RefreshedToken_Should_HaveExtendedExpiry_When_Refreshed()
125+
{
126+
// Arrange
127+
var token = await _auth.GetRootAdminTokenAsync();
128+
var handler = new JwtSecurityTokenHandler();
129+
var originalJwt = handler.ReadJwtToken(token.AccessToken);
130+
131+
// Act — refresh the token
132+
using var client = _factory.CreateClient();
133+
var request = new HttpRequestMessage(HttpMethod.Post, $"{TestConstants.IdentityBasePath}/token/refresh");
134+
request.Headers.Add("tenant", TestConstants.RootTenantId);
135+
request.Content = JsonContent.Create(new
136+
{
137+
token = token.AccessToken,
138+
refreshToken = token.RefreshToken
139+
});
140+
var response = await client.SendAsync(request);
141+
var refreshed = await response.Content.ReadFromJsonAsync<TokenRefreshResult>();
142+
143+
// Assert — the new token should have a fresh expiry
144+
var newJwt = handler.ReadJwtToken(refreshed!.Token);
145+
var newExpiresIn = newJwt.ValidTo - DateTime.UtcNow;
146+
newExpiresIn.TotalMinutes.ShouldBeGreaterThan(1);
147+
}
148+
149+
[Theory]
150+
[InlineData(-5, true)] // already expired — near expiry
151+
[InlineData(10, true)] // 10 seconds left — within 30s buffer
152+
[InlineData(29, true)] // 29 seconds left — within 30s buffer
153+
[InlineData(60, false)] // 1 minute left — not near expiry
154+
[InlineData(300, false)] // 5 minutes left — not near expiry
155+
public void IsTokenNearExpiry_Should_DetectCorrectly(int secondsUntilExpiry, bool expectedNearExpiry)
156+
{
157+
// Arrange — create a JWT with the specified expiry
158+
var key = new SymmetricSecurityKey(
159+
Encoding.UTF8.GetBytes(TestConstants.JwtSigningKey));
160+
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
161+
162+
var tokenDescriptor = new SecurityTokenDescriptor
163+
{
164+
Subject = new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, "test-user")]),
165+
NotBefore = DateTime.UtcNow.AddMinutes(-10),
166+
Expires = DateTime.UtcNow.AddSeconds(secondsUntilExpiry),
167+
Issuer = TestConstants.JwtIssuer,
168+
Audience = TestConstants.JwtAudience,
169+
SigningCredentials = credentials
170+
};
171+
172+
var handler = new JwtSecurityTokenHandler();
173+
var jwt = handler.CreateToken(tokenDescriptor);
174+
var tokenString = handler.WriteToken(jwt);
175+
176+
// Act — check if the token is near expiry using the same logic as AuthorizationHeaderHandler
177+
var jwtRead = handler.ReadJwtToken(tokenString);
178+
bool isNearExpiry = jwtRead.ValidTo <= DateTime.UtcNow.Add(TimeSpan.FromSeconds(30));
179+
180+
// Assert
181+
isNearExpiry.ShouldBe(expectedNearExpiry);
182+
}
183+
}

0 commit comments

Comments
 (0)