diff --git a/CHANGELOG.md b/CHANGELOG.md index bd856eb..9588b8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Enhancements - Allow passing additional headers when encoding a JWT. +- Allow passing leeway parameter for date checks when verifying a JWT. ## 2.1.0 diff --git a/README.md b/README.md index 830f70d..9a6fae2 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,12 @@ try JWT.decode("eyJh...5w", algorithms: [ ]) ``` +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: + +```swift +try JWT.decode("eyJh...5w", algorithm: .hs256("secret".data(using: .utf8)!), leeway: 10) +``` + #### Supported claims The library supports validating the following claims: diff --git a/Sources/ClaimSet.swift b/Sources/ClaimSet.swift index 7626498..7dcf872 100644 --- a/Sources/ClaimSet.swift +++ b/Sources/ClaimSet.swift @@ -93,7 +93,7 @@ extension ClaimSet { // MARK: Validations extension ClaimSet { - public func validate(audience: String? = nil, issuer: String? = nil) throws { + public func validate(audience: String? = nil, issuer: String? = nil, leeway: TimeInterval = 0) throws { if let issuer = issuer { try validateIssuer(issuer) } @@ -101,10 +101,10 @@ extension ClaimSet { if let audience = audience { try validateAudience(audience) } - - try validateExpiary() - try validateNotBefore() - try validateIssuedAt() + + try validateExpiary(leeway: leeway) + try validateNotBefore(leeway: leeway) + try validateIssuedAt(leeway: leeway) } public func validateAudience(_ audience: String) throws { @@ -131,16 +131,16 @@ extension ClaimSet { } } - public func validateExpiary() throws { - try validateDate(claims, key: "exp", comparison: .orderedAscending, failure: .expiredSignature, decodeError: "Expiration time claim (exp) must be an integer") + public func validateExpiary(leeway: TimeInterval = 0) throws { + try validateDate(claims, key: "exp", comparison: .orderedAscending, leeway: (-1 * leeway), failure: .expiredSignature, decodeError: "Expiration time claim (exp) must be an integer") } - public func validateNotBefore() throws { - try validateDate(claims, key: "nbf", comparison: .orderedDescending, failure: .immatureSignature, decodeError: "Not before claim (nbf) must be an integer") + public func validateNotBefore(leeway: TimeInterval = 0) throws { + try validateDate(claims, key: "nbf", comparison: .orderedDescending, leeway: leeway, failure: .immatureSignature, decodeError: "Not before claim (nbf) must be an integer") } - public func validateIssuedAt() throws { - try validateDate(claims, key: "iat", comparison: .orderedDescending, failure: .invalidIssuedAt, decodeError: "Issued at claim (iat) must be an integer") + public func validateIssuedAt(leeway: TimeInterval = 0) throws { + try validateDate(claims, key: "iat", comparison: .orderedDescending, leeway: leeway, failure: .invalidIssuedAt, decodeError: "Issued at claim (iat) must be an integer") } } diff --git a/Sources/Claims.swift b/Sources/Claims.swift index ad07027..d1df6f4 100644 --- a/Sources/Claims.swift +++ b/Sources/Claims.swift @@ -1,6 +1,6 @@ import Foundation -func validateDate(_ payload: Payload, key: String, comparison: ComparisonResult, failure: InvalidToken, decodeError: String) throws { +func validateDate(_ payload: Payload, key: String, comparison: ComparisonResult, leeway: TimeInterval = 0, failure: InvalidToken, decodeError: String) throws { if payload[key] == nil { return } @@ -8,8 +8,8 @@ func validateDate(_ payload: Payload, key: String, comparison: ComparisonResult, guard let date = extractDate(payload: payload, key: key) else { throw InvalidToken.decodeError(decodeError) } - - if date.compare(Date()) == comparison { + + if date.compare(Date().addingTimeInterval(leeway)) == comparison { throw failure } } diff --git a/Sources/Decode.swift b/Sources/Decode.swift index ce802d7..3054160 100644 --- a/Sources/Decode.swift +++ b/Sources/Decode.swift @@ -47,11 +47,11 @@ public enum InvalidToken: CustomStringConvertible, Error { /// Decode a JWT -public func decode(_ jwt: String, algorithms: [Algorithm], verify: Bool = true, audience: String? = nil, issuer: String? = nil) throws -> ClaimSet { +public func decode(_ jwt: String, algorithms: [Algorithm], verify: Bool = true, audience: String? = nil, issuer: String? = nil, leeway: TimeInterval = 0) throws -> ClaimSet { let (header, claims, signature, signatureInput) = try load(jwt) if verify { - try claims.validate(audience: audience, issuer: issuer) + try claims.validate(audience: audience, issuer: issuer, leeway: leeway) try verifySignature(algorithms, header: header, signingInput: signatureInput, signature: signature) } @@ -59,8 +59,8 @@ public func decode(_ jwt: String, algorithms: [Algorithm], verify: Bool = true, } /// Decode a JWT -public func decode(_ jwt: String, algorithm: Algorithm, verify: Bool = true, audience: String? = nil, issuer: String? = nil) throws -> ClaimSet { - return try decode(jwt, algorithms: [algorithm], verify: verify, audience: audience, issuer: issuer) +public func decode(_ jwt: String, algorithm: Algorithm, verify: Bool = true, audience: String? = nil, issuer: String? = nil, leeway: TimeInterval = 0) throws -> ClaimSet { + return try decode(jwt, algorithms: [algorithm], verify: verify, audience: audience, issuer: issuer, leeway: leeway) } /// Decode a JWT diff --git a/Tests/JWTTests/JWTTests.swift b/Tests/JWTTests/JWTTests.swift index a0e1b8f..b8ffe50 100644 --- a/Tests/JWTTests/JWTTests.swift +++ b/Tests/JWTTests/JWTTests.swift @@ -279,6 +279,121 @@ class DecodeTests: XCTestCase { } } +class ValidationTests: XCTestCase { + func testClaimJustExpiredWithoutLeeway() { + var claims = ClaimSet() + claims.expiration = Date().addingTimeInterval(-1) + + do { + try claims.validateExpiary() + XCTFail("InvalidToken.expiredSignature error should have been thrown.") + } catch InvalidToken.expiredSignature { + // Correct error thrown + } catch { + XCTFail("Unexpected error while validating exp claim.") + } + } + + func testClaimJustNotExpiredWithoutLeeway() { + var claims = ClaimSet() + claims.expiration = Date().addingTimeInterval(-1) + + do { + try claims.validateExpiary(leeway: 2) + } catch { + XCTFail("Unexpected error while validating exp claim that should be valid with leeway.") + } + } + + func testNotBeforeIsImmatureSignatureWithoutLeeway() { + var claims = ClaimSet() + claims.notBefore = Date().addingTimeInterval(1) + + do { + try claims.validateNotBefore() + XCTFail("InvalidToken.immatureSignature error should have been thrown.") + } catch InvalidToken.immatureSignature { + // Correct error thrown + } catch { + XCTFail("Unexpected error while validating nbf claim.") + } + } + + func testNotBeforeIsValidWithLeeway() { + var claims = ClaimSet() + claims.notBefore = Date().addingTimeInterval(1) + + do { + try claims.validateNotBefore(leeway: 2) + } catch { + XCTFail("Unexpected error while validating nbf claim that should be valid with leeway.") + } + } + + func testIssuedAtIsInFutureWithoutLeeway() { + var claims = ClaimSet() + claims.issuedAt = Date().addingTimeInterval(1) + + do { + try claims.validateIssuedAt() + XCTFail("InvalidToken.invalidIssuedAt error should have been thrown.") + } catch InvalidToken.invalidIssuedAt { + // Correct error thrown + } catch { + XCTFail("Unexpected error while validating iat claim.") + } + } + + func testIssuedAtIsValidWithLeeway() { + var claims = ClaimSet() + claims.issuedAt = Date().addingTimeInterval(1) + + do { + try claims.validateIssuedAt(leeway: 2) + } catch { + XCTFail("Unexpected error while validating iat claim that should be valid with leeway.") + } + } +} + +class IntegrationTests: XCTestCase { + func testVerificationFailureWithoutLeeway() { + let token = JWT.encode(.none) { builder in + builder.issuer = "fuller.li" + builder.audience = "cocoapods" + builder.expiration = Date().addingTimeInterval(-1) // Token expired one second ago + builder.notBefore = Date().addingTimeInterval(1) // Token starts being valid in one second + builder.issuedAt = Date().addingTimeInterval(1) // Token is issued one second in the future + } + + do { + let _ = try JWT.decode(token, algorithm: .none, leeway: 0) + XCTFail("InvalidToken error should have been thrown.") + } catch is InvalidToken { + // Correct error thrown + } catch { + XCTFail("Unexpected error type while verifying token.") + } + } + + func testVerificationSuccessWithLeeway() { + let token = JWT.encode(.none) { builder in + builder.issuer = "fuller.li" + builder.audience = "cocoapods" + builder.expiration = Date().addingTimeInterval(-1) // Token expired one second ago + builder.notBefore = Date().addingTimeInterval(1) // Token starts being valid in one second + builder.issuedAt = Date().addingTimeInterval(1) // Token is issued one second in the future + } + + do { + let _ = try JWT.decode(token, algorithm: .none, leeway: 2) + // Due to leeway no error gets thrown. + } catch { + XCTFail("Unexpected error type while verifying token.") + } + } +} + // MARK: Helpers func assertSuccess(_ decoder: @autoclosure () throws -> Payload, closure: ((Payload) -> Void)? = nil) {