Skip to content

Commit a891e63

Browse files
authored
Merge pull request #83 from JanBrinker/JanBrinker/implement-leeway-for-date-validation
Implement leeway for date validation
2 parents c5e4b5d + a6fa6d8 commit a891e63

File tree

6 files changed

+140
-18
lines changed

6 files changed

+140
-18
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Enhancements
66

77
- Allow passing additional headers when encoding a JWT.
8+
- Allow passing leeway parameter for date checks when verifying a JWT.
89

910

1011
## 2.1.0

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ try JWT.decode("eyJh...5w", algorithms: [
6868
])
6969
```
7070

71+
You might also want to give your iat, exp and nbf checks some kind of leeway to account for skewed clocks. You can do this by passing a `leeway` parameter like this:
72+
73+
```swift
74+
try JWT.decode("eyJh...5w", algorithm: .hs256("secret".data(using: .utf8)!), leeway: 10)
75+
```
76+
7177
#### Supported claims
7278

7379
The library supports validating the following claims:

Sources/ClaimSet.swift

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -93,18 +93,18 @@ extension ClaimSet {
9393
// MARK: Validations
9494

9595
extension ClaimSet {
96-
public func validate(audience: String? = nil, issuer: String? = nil) throws {
96+
public func validate(audience: String? = nil, issuer: String? = nil, leeway: TimeInterval = 0) throws {
9797
if let issuer = issuer {
9898
try validateIssuer(issuer)
9999
}
100100

101101
if let audience = audience {
102102
try validateAudience(audience)
103103
}
104-
105-
try validateExpiary()
106-
try validateNotBefore()
107-
try validateIssuedAt()
104+
105+
try validateExpiary(leeway: leeway)
106+
try validateNotBefore(leeway: leeway)
107+
try validateIssuedAt(leeway: leeway)
108108
}
109109

110110
public func validateAudience(_ audience: String) throws {
@@ -131,16 +131,16 @@ extension ClaimSet {
131131
}
132132
}
133133

134-
public func validateExpiary() throws {
135-
try validateDate(claims, key: "exp", comparison: .orderedAscending, failure: .expiredSignature, decodeError: "Expiration time claim (exp) must be an integer")
134+
public func validateExpiary(leeway: TimeInterval = 0) throws {
135+
try validateDate(claims, key: "exp", comparison: .orderedAscending, leeway: (-1 * leeway), failure: .expiredSignature, decodeError: "Expiration time claim (exp) must be an integer")
136136
}
137137

138-
public func validateNotBefore() throws {
139-
try validateDate(claims, key: "nbf", comparison: .orderedDescending, failure: .immatureSignature, decodeError: "Not before claim (nbf) must be an integer")
138+
public func validateNotBefore(leeway: TimeInterval = 0) throws {
139+
try validateDate(claims, key: "nbf", comparison: .orderedDescending, leeway: leeway, failure: .immatureSignature, decodeError: "Not before claim (nbf) must be an integer")
140140
}
141141

142-
public func validateIssuedAt() throws {
143-
try validateDate(claims, key: "iat", comparison: .orderedDescending, failure: .invalidIssuedAt, decodeError: "Issued at claim (iat) must be an integer")
142+
public func validateIssuedAt(leeway: TimeInterval = 0) throws {
143+
try validateDate(claims, key: "iat", comparison: .orderedDescending, leeway: leeway, failure: .invalidIssuedAt, decodeError: "Issued at claim (iat) must be an integer")
144144
}
145145
}
146146

Sources/Claims.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import Foundation
22

3-
func validateDate(_ payload: Payload, key: String, comparison: ComparisonResult, failure: InvalidToken, decodeError: String) throws {
3+
func validateDate(_ payload: Payload, key: String, comparison: ComparisonResult, leeway: TimeInterval = 0, failure: InvalidToken, decodeError: String) throws {
44
if payload[key] == nil {
55
return
66
}
77

88
guard let date = extractDate(payload: payload, key: key) else {
99
throw InvalidToken.decodeError(decodeError)
1010
}
11-
12-
if date.compare(Date()) == comparison {
11+
12+
if date.compare(Date().addingTimeInterval(leeway)) == comparison {
1313
throw failure
1414
}
1515
}

Sources/Decode.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,20 +47,20 @@ public enum InvalidToken: CustomStringConvertible, Error {
4747

4848

4949
/// Decode a JWT
50-
public func decode(_ jwt: String, algorithms: [Algorithm], verify: Bool = true, audience: String? = nil, issuer: String? = nil) throws -> ClaimSet {
50+
public func decode(_ jwt: String, algorithms: [Algorithm], verify: Bool = true, audience: String? = nil, issuer: String? = nil, leeway: TimeInterval = 0) throws -> ClaimSet {
5151
let (header, claims, signature, signatureInput) = try load(jwt)
5252

5353
if verify {
54-
try claims.validate(audience: audience, issuer: issuer)
54+
try claims.validate(audience: audience, issuer: issuer, leeway: leeway)
5555
try verifySignature(algorithms, header: header, signingInput: signatureInput, signature: signature)
5656
}
5757

5858
return claims
5959
}
6060

6161
/// Decode a JWT
62-
public func decode(_ jwt: String, algorithm: Algorithm, verify: Bool = true, audience: String? = nil, issuer: String? = nil) throws -> ClaimSet {
63-
return try decode(jwt, algorithms: [algorithm], verify: verify, audience: audience, issuer: issuer)
62+
public func decode(_ jwt: String, algorithm: Algorithm, verify: Bool = true, audience: String? = nil, issuer: String? = nil, leeway: TimeInterval = 0) throws -> ClaimSet {
63+
return try decode(jwt, algorithms: [algorithm], verify: verify, audience: audience, issuer: issuer, leeway: leeway)
6464
}
6565

6666
/// Decode a JWT

Tests/JWTTests/JWTTests.swift

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,121 @@ class DecodeTests: XCTestCase {
279279
}
280280
}
281281

282+
class ValidationTests: XCTestCase {
283+
func testClaimJustExpiredWithoutLeeway() {
284+
var claims = ClaimSet()
285+
claims.expiration = Date().addingTimeInterval(-1)
286+
287+
do {
288+
try claims.validateExpiary()
289+
XCTFail("InvalidToken.expiredSignature error should have been thrown.")
290+
} catch InvalidToken.expiredSignature {
291+
// Correct error thrown
292+
} catch {
293+
XCTFail("Unexpected error while validating exp claim.")
294+
}
295+
}
296+
297+
func testClaimJustNotExpiredWithoutLeeway() {
298+
var claims = ClaimSet()
299+
claims.expiration = Date().addingTimeInterval(-1)
300+
301+
do {
302+
try claims.validateExpiary(leeway: 2)
303+
} catch {
304+
XCTFail("Unexpected error while validating exp claim that should be valid with leeway.")
305+
}
306+
}
307+
308+
func testNotBeforeIsImmatureSignatureWithoutLeeway() {
309+
var claims = ClaimSet()
310+
claims.notBefore = Date().addingTimeInterval(1)
311+
312+
do {
313+
try claims.validateNotBefore()
314+
XCTFail("InvalidToken.immatureSignature error should have been thrown.")
315+
} catch InvalidToken.immatureSignature {
316+
// Correct error thrown
317+
} catch {
318+
XCTFail("Unexpected error while validating nbf claim.")
319+
}
320+
}
321+
322+
func testNotBeforeIsValidWithLeeway() {
323+
var claims = ClaimSet()
324+
claims.notBefore = Date().addingTimeInterval(1)
325+
326+
do {
327+
try claims.validateNotBefore(leeway: 2)
328+
} catch {
329+
XCTFail("Unexpected error while validating nbf claim that should be valid with leeway.")
330+
}
331+
}
332+
333+
func testIssuedAtIsInFutureWithoutLeeway() {
334+
var claims = ClaimSet()
335+
claims.issuedAt = Date().addingTimeInterval(1)
336+
337+
do {
338+
try claims.validateIssuedAt()
339+
XCTFail("InvalidToken.invalidIssuedAt error should have been thrown.")
340+
} catch InvalidToken.invalidIssuedAt {
341+
// Correct error thrown
342+
} catch {
343+
XCTFail("Unexpected error while validating iat claim.")
344+
}
345+
}
346+
347+
func testIssuedAtIsValidWithLeeway() {
348+
var claims = ClaimSet()
349+
claims.issuedAt = Date().addingTimeInterval(1)
350+
351+
do {
352+
try claims.validateIssuedAt(leeway: 2)
353+
} catch {
354+
XCTFail("Unexpected error while validating iat claim that should be valid with leeway.")
355+
}
356+
}
357+
}
358+
359+
class IntegrationTests: XCTestCase {
360+
func testVerificationFailureWithoutLeeway() {
361+
let token = JWT.encode(.none) { builder in
362+
builder.issuer = "fuller.li"
363+
builder.audience = "cocoapods"
364+
builder.expiration = Date().addingTimeInterval(-1) // Token expired one second ago
365+
builder.notBefore = Date().addingTimeInterval(1) // Token starts being valid in one second
366+
builder.issuedAt = Date().addingTimeInterval(1) // Token is issued one second in the future
367+
}
368+
369+
do {
370+
let _ = try JWT.decode(token, algorithm: .none, leeway: 0)
371+
XCTFail("InvalidToken error should have been thrown.")
372+
} catch is InvalidToken {
373+
// Correct error thrown
374+
} catch {
375+
XCTFail("Unexpected error type while verifying token.")
376+
}
377+
}
378+
379+
func testVerificationSuccessWithLeeway() {
380+
let token = JWT.encode(.none) { builder in
381+
builder.issuer = "fuller.li"
382+
builder.audience = "cocoapods"
383+
builder.expiration = Date().addingTimeInterval(-1) // Token expired one second ago
384+
builder.notBefore = Date().addingTimeInterval(1) // Token starts being valid in one second
385+
builder.issuedAt = Date().addingTimeInterval(1) // Token is issued one second in the future
386+
}
387+
388+
do {
389+
let _ = try JWT.decode(token, algorithm: .none, leeway: 2)
390+
// Due to leeway no error gets thrown.
391+
} catch {
392+
XCTFail("Unexpected error type while verifying token.")
393+
}
394+
}
395+
}
396+
282397
// MARK: Helpers
283398

284399
func assertSuccess(_ decoder: @autoclosure () throws -> Payload, closure: ((Payload) -> Void)? = nil) {

0 commit comments

Comments
 (0)