Skip to content

Commit 4a412e7

Browse files
authored
Use expirationDurationInSeconds for token refresh window (#243)
The 55-minute refresh window was hardcoded inline in `nextValidToken` while the named `expirationDurationInSeconds` constant sat unused — so changing the constant silently had no effect. Reference the constant directly so it is the single source of truth. Also strengthen the token-manager tests: - `testTokenReusedJustBeforeBoundaryAndRefreshedAtBoundary` pins the `[0, 55min)` reuse window at the exact boundary. - `testRefreshedTokenIsStructurallyValid` decodes the refreshed JWT and asserts the header/claims, rather than relying solely on string inequality (ECDSA signatures are randomized, so a different string alone proves little).
1 parent 356534b commit 4a412e7

2 files changed

Lines changed: 46 additions & 2 deletions

File tree

Sources/APNSCore/APNSAuthenticationTokenManager.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ public final actor APNSAuthenticationTokenManager<Clock: _Concurrency.Clock> whe
7070
/// First we check if there is a previously generated token
7171
/// and if that token is still valid.
7272
if let lastGeneratedToken = lastGeneratedToken,
73-
lastGeneratedToken.issuedAt.duration(to: self.clock.now) < .seconds(60 * 55) {
73+
lastGeneratedToken.issuedAt.duration(to: self.clock.now) < self.expirationDurationInSeconds {
7474
/// The last generated token is still valid
7575
return lastGeneratedToken.token
7676
} else {

Tests/APNSTests/APNSAuthenticationTokenManagerTests.swift

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,58 @@ final class APNSAuthenticationTokenManagerTests: XCTestCase {
9898

9999
func testTokenIsRefreshed() async throws {
100100
let token1 = try await tokenManager.nextValidToken
101-
101+
102102
// 56 minutes later
103103
let temp = clock.now.advanced(by: .init(secondsComponent: 3360, attosecondsComponent: 0))
104104
clock.now = temp
105105
let token2 = try await tokenManager.nextValidToken
106106

107107
XCTAssertNotEqual(token1, token2)
108108
}
109+
110+
/// The refresh window is `[0, 55min)`: a token is still considered valid one
111+
/// second before 55 minutes, and refreshed at exactly 55 minutes.
112+
func testTokenReusedJustBeforeBoundaryAndRefreshedAtBoundary() async throws {
113+
let token1 = try await tokenManager.nextValidToken
114+
115+
// 54:59 — still inside the window, so the cached token is returned.
116+
clock.now = clock.now.advanced(by: .init(secondsComponent: 3299, attosecondsComponent: 0))
117+
let reused = try await tokenManager.nextValidToken
118+
XCTAssertEqual(token1, reused)
119+
120+
// 55:00 — `duration(to:)` is no longer `< 55min`, so a fresh token is generated.
121+
clock.now = clock.now.advanced(by: .init(secondsComponent: 1, attosecondsComponent: 0))
122+
let refreshed = try await tokenManager.nextValidToken
123+
XCTAssertNotEqual(token1, refreshed)
124+
}
125+
126+
/// A refreshed token must be a well-formed JWT with the correct claims — not merely
127+
/// a different string (ECDSA signatures are randomized, so string inequality alone is weak).
128+
func testRefreshedTokenIsStructurallyValid() async throws {
129+
_ = try await tokenManager.nextValidToken
130+
131+
clock.now = clock.now.advanced(by: .init(secondsComponent: 3360, attosecondsComponent: 0))
132+
let refreshed = try await tokenManager.nextValidToken
133+
134+
let segments = try XCTUnwrap(refreshed.split(separator: " ").last).split(separator: ".")
135+
XCTAssertEqual(segments.count, 3, "Expected a `header.payload.signature` JWT")
136+
137+
let header = try decodeSegment(segments[0])
138+
XCTAssertTrue(header.contains("\"kid\": \"bar\""))
139+
XCTAssertTrue(header.contains("\"alg\": \"ES256\""))
140+
141+
let payload = try decodeSegment(segments[1])
142+
XCTAssertTrue(payload.contains("\"iss\": \"foo\""))
143+
XCTAssertTrue(payload.contains("\"kid\": \"bar\""))
144+
}
145+
146+
private func decodeSegment(_ segment: Substring) throws -> String {
147+
let bytes = try Base64.decode(
148+
string: String(segment),
149+
options: [.base64UrlAlphabet, .omitPaddingCharacter]
150+
)
151+
return try XCTUnwrap(String(bytes: bytes, encoding: .utf8))
152+
}
109153
}
110154

111155
final class TestClock<Duration: DurationProtocol & Hashable>: Clock {

0 commit comments

Comments
 (0)