From 83afce04c6740c40431d88f0cea0d57a50df828d Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Wed, 20 Nov 2024 05:36:56 -0800 Subject: [PATCH] Added migratory types with deprecations to guide users updating from version 4 to version 5 --- Sources/JWTKit/ECDSA/ECDSAKey.swift | 224 +++++++++++++++++++ Sources/JWTKit/ECDSA/JWTSigner+ECDSA.swift | 70 ++++++ Sources/JWTKit/JWTParser.swift | 7 + Sources/JWTKit/JWTPayload.swift | 11 + Sources/JWTKit/JWTSerializer.swift | 2 +- Sources/JWTKit/JWTSigner.swift | 71 ++++++ Sources/JWTKit/JWTSigners.swift | 245 ++++++++++++++++++++- Sources/JWTKit/Utilities/Base64URL.swift | 4 + Tests/JWTKitTests/JWTMigrationTests.swift | 53 +++++ 9 files changed, 685 insertions(+), 2 deletions(-) create mode 100644 Tests/JWTKitTests/JWTMigrationTests.swift diff --git a/Sources/JWTKit/ECDSA/ECDSAKey.swift b/Sources/JWTKit/ECDSA/ECDSAKey.swift index 5e2388d7..2c632a77 100644 --- a/Sources/JWTKit/ECDSA/ECDSAKey.swift +++ b/Sources/JWTKit/ECDSA/ECDSAKey.swift @@ -1,5 +1,6 @@ import Foundation @_implementationOnly import CJWTKitBoringSSL +import Crypto public final class ECDSAKey: OpenSSLKey { @@ -11,6 +12,7 @@ public final class ECDSAKey: OpenSSLKey { case ed448 = "Ed448" } + @available(*, deprecated, message: "Unavailable in v5. Please use ES256PrivateKey(), ES384PrivateKey(), or ES512PrivateKey() instead.") public static func generate(curve: Curve = .p521) throws -> ECDSAKey { guard let c = CJWTKitBoringSSL_EC_KEY_new_by_curve_name(curve.cName) else { throw JWTError.signingAlgorithmFailure(ECDSAError.newKeyByCurveFailure) @@ -34,6 +36,7 @@ public final class ECDSAKey: OpenSSLKey { /// /// - parameters: /// - pem: Contents of pem file. + @available(*, deprecated, message: "Unavailable in v5. Please use ES256PublicKey(certificate:), ES384PublicKey(certificate:), or ES512PublicKey(certificate:) instead. Note that more interfaces for importing keys is available once you update fully to v5.") public static func certificate(pem string: String) throws -> ECDSAKey { try self.certificate(pem: [UInt8](string.utf8)) } @@ -52,6 +55,7 @@ public final class ECDSAKey: OpenSSLKey { /// /// - parameters: /// - pem: Contents of pem file. + @available(*, deprecated, message: "Unavailable in v5. Please use ES256PublicKey(certificate:), ES384PublicKey(certificate:), or ES512PublicKey(certificate:) instead. Note that more interfaces for importing keys is available once you update fully to v5.") public static func certificate(pem data: Data) throws -> ECDSAKey where Data: DataProtocol { @@ -68,10 +72,12 @@ public final class ECDSAKey: OpenSSLKey { return self.init(c) } + @available(*, deprecated, message: "Unavailable in v5. Please use ES256PublicKey(pem:), ES384PublicKey(pem:), or ES512PublicKey(pem:) instead. Note that more interfaces for importing keys is available once you update fully to v5.") public static func `public`(pem string: String) throws -> ECDSAKey { try .public(pem: [UInt8](string.utf8)) } + @available(*, deprecated, message: "Unavailable in v5. Please use ES256PublicKey(pem:), ES384PublicKey(pem:), or ES512PublicKey(pem:) instead. Note that more interfaces for importing keys is available once you update fully to v5.") public static func `public`(pem data: Data) throws -> ECDSAKey where Data: DataProtocol { @@ -81,10 +87,12 @@ public final class ECDSAKey: OpenSSLKey { return self.init(c) } + @available(*, deprecated, message: "Unavailable in v5. Please use ES256PrivateKey(pem:), ES384PrivateKey(pem:), or ES512PrivateKey(pem:) instead. Note that more interfaces for importing keys is available once you update fully to v5.") public static func `private`(pem string: String) throws -> ECDSAKey { try .private(pem: [UInt8](string.utf8)) } + @available(*, deprecated, message: "Unavailable in v5. Please use ES256PrivateKey(pem:), ES384PrivateKey(pem:), or ES512PrivateKey(pem:) instead. Note that more interfaces for importing keys is available once you update fully to v5.") public static func `private`(pem data: Data) throws -> ECDSAKey where Data: DataProtocol { @@ -100,6 +108,7 @@ public final class ECDSAKey: OpenSSLKey { self.c = c } + @available(*, deprecated, message: "Unavailable in v5. Please use ES256PublicKey(parameters:), ES384PublicKey(parameters:), or ES512PublicKey(parameters:) instead. Note that more interfaces for importing private keys is available once you update fully to v5.") public convenience init(parameters: Parameters, curve: Curve = .p521, privateKey: String? = nil) throws { guard let c = CJWTKitBoringSSL_EC_KEY_new_by_curve_name(curve.cName) else { throw JWTError.signingAlgorithmFailure(ECDSAError.newKeyByCurveFailure) @@ -190,3 +199,218 @@ extension ECDSAKey.Curve { } } } + +#if compiler(>=6) +public protocol ECDSACurveType: Sendable { + static var curve: ECDSAKey.Curve { get } +} + +extension P256: ECDSACurveType, @unchecked @retroactive Sendable { + static public var curve: ECDSAKey.Curve { .p256 } +} + +extension P384: ECDSACurveType, @unchecked @retroactive Sendable { + static public var curve: ECDSAKey.Curve { .p384 } +} + +extension P521: ECDSACurveType, @unchecked @retroactive Sendable { + static public var curve: ECDSAKey.Curve { .p521 } +} +#else +public protocol ECDSACurveType { + static var curve: ECDSAKey.Curve { get } +} + +extension P256: ECDSACurveType { + static public var curve: ECDSAKey.Curve { .p256 } +} + +extension P384: ECDSACurveType { + static public var curve: ECDSAKey.Curve { .p384 } +} + +extension P521: ECDSACurveType { + static public var curve: ECDSAKey.Curve { .p521 } +} +#endif + +public typealias ES256PublicKey = ECDSA.PublicKey +public typealias ES256PrivateKey = ECDSA.PrivateKey + +public typealias ES384PublicKey = ECDSA.PublicKey +public typealias ES384PrivateKey = ECDSA.PrivateKey + +public typealias ES512PublicKey = ECDSA.PublicKey +public typealias ES512PrivateKey = ECDSA.PrivateKey + +public enum ECDSA { + /// ECDSA.PublicKey was introduced in v5 and replaces ``ECDSAKey``. + /// + /// - Note: Please migrate over to ``ECDSA/PublicKey`` before updating to v5, though if you plan on remaining on v4, ``ECDSAKey`` can continue to be used. + public struct PublicKey { + let key: ECDSAKey + init(key: ECDSAKey) { self.key = key } + + public var curve: ECDSAKey.Curve? { key.curve } + public var parameters: ECDSAKey.Parameters? { key.parameters } + + /// Creates an ``ECDSA.PublicKey`` instance from a PEM encoded certificate string. + /// + /// - Parameter pem: The PEM encoded certificate string. + /// - Throws: If there is a problem parsing the certificate or deriving the public key. + /// - Returns: A new ``ECDSAKey`` instance with the public key from the certificate. + public init(certificate pem: String) throws { + key = try ECDSAKey.certificate(pem: pem) + } + + /// Creates an ``ECDSA.PublicKey`` instance from a PEM encoded certificate data. + /// + /// - Parameter pem: The PEM encoded certificate data. + /// - Throws: If there is a problem parsing the certificate or deriving the public key. + /// - Returns: A new ``ECDSA.PublicKey`` instance with the public key from the certificate. + public init(certificate pem: Data) throws { + key = try ECDSAKey.certificate(pem: pem) + } + + /// Creates an ``ECDSA.PublicKey`` instance from a PEM encoded public key string. + /// + /// - Parameter pem: The PEM encoded public key string. + /// - Throws: If there is a problem parsing the public key. + /// - Returns: A new ``ECDSA.PublicKey`` instance with the public key from the certificate. + public init(pem string: String) throws { + key = try ECDSAKey.public(pem: string) + } + + /// Creates an ``ECDSA.PublicKey`` instance from a PEM encoded public key data. + /// + /// - Parameter pem: The PEM encoded public key data. + /// - Throws: If there is a problem parsing the public key. + /// - Returns: A new ``ECDSA.PublicKey`` instance with the public key from the certificate. + public init(pem data: Data) throws { + key = try ECDSAKey.public(pem: data) + } + + /// Initializes a new ``ECDSA.PublicKey` with ECDSA parameters. + /// + /// - Parameters: + /// - parameters: The ``ECDSAParameters`` tuple containing the x and y coordinates of the public key. These coordinates should be base64 URL encoded strings. + /// + /// - Throws: + /// - ``JWTError/generic`` with the identifier `ecCoordinates` if the x and y coordinates from `parameters` cannot be interpreted as base64 encoded data. + /// - ``JWTError/generic`` with the identifier `ecPrivateKey` if the provided `privateKey` is non-nil but cannot be interpreted as a valid `PrivateKey`. + /// + /// - Note: + /// The ``ECDSAParameters`` tuple is assumed to have x and y properties that are base64 URL encoded strings representing the respective coordinates of an ECDSA public key. + public init(parameters: ECDSAKey.Parameters) throws { + key = try ECDSAKey(parameters: parameters, curve: Curve.curve, privateKey: nil) + } + } + + /// ECDSA.PrivateKey was introduced in v5 and replaces ``ECDSAKey``. + /// + /// - Note: Please migrate over to ``ECDSA/PrivateKey`` before updating to v5, though if you plan on remaining on v4, ``ECDSAKey`` can continue to be used. + public struct PrivateKey { + let key: ECDSAKey + init(key: ECDSAKey) { self.key = key } + + public var curve: ECDSAKey.Curve? { key.curve } + public var parameters: ECDSAKey.Parameters? { key.parameters } + + /// Creates an ``ECDSA.PrivateKey`` instance from a PEM encoded private key string. + /// + /// - Parameter pem: The PEM encoded private key string. + /// - Throws: If there is a problem parsing the private key. + /// - Returns: A new ``ECDSA.PrivateKey`` instance with the private key. + public init(pem string: String) throws { + key = try ECDSAKey.public(pem: string) + } + + /// Creates an ``ECDSA.PrivateKey`` instance from a PEM encoded private key data. + /// + /// - Parameter pem: The PEM encoded private key data. + /// - Throws: If there is a problem parsing the private key. + /// - Returns: A new ``ECDSA.PrivateKey`` instance with the private key. + public init(pem data: Data) throws { + key = try ECDSAKey.public(pem: data) + } + + /// Generates a new ECDSA key. + /// + /// - Returns: A new ``ECDSA.PrivateKey`` instance with the generated key. + public init() { + key = try! ECDSAKey.generate(curve: Curve.curve) + } + } +} + +extension ECDSA.PublicKey { + public init(backing: Curve.Signing.PublicKey) throws { + let representation = backing.rawRepresentation + try self.init(parameters: ECDSAKey.Parameters( + x: representation.prefix(representation.count/2).base64URLEncodedString(), + y: representation.suffix(representation.count/2).base64URLEncodedString() + )) + } +} + +extension ECDSA.PublicKey { + public init(backing: Curve.Signing.PublicKey) throws { + let representation = backing.rawRepresentation + try self.init(parameters: ECDSAKey.Parameters( + x: representation.prefix(representation.count/2).base64URLEncodedString(), + y: representation.suffix(representation.count/2).base64URLEncodedString() + )) + } +} + +extension ECDSA.PublicKey where Curve == P521 { + public init(backing: Curve.Signing.PublicKey) throws { + let representation = backing.rawRepresentation + try self.init(parameters: ECDSAKey.Parameters( + x: representation.prefix(representation.count/2).base64URLEncodedString(), + y: representation.suffix(representation.count/2).base64URLEncodedString() + )) + } +} + +extension ECDSA.PrivateKey { + public init(backing: Curve.Signing.PrivateKey) throws { + let representation = backing.publicKey.rawRepresentation + try self.init(key: ECDSAKey( + parameters: ECDSAKey.Parameters( + x: representation.prefix(representation.count/2).base64URLEncodedString(), + y: representation.suffix(representation.count/2).base64URLEncodedString() + ), + curve: Curve.curve, + privateKey: backing.rawRepresentation.base64URLEncodedString() + )) + } +} + +extension ECDSA.PrivateKey { + public init(backing: Curve.Signing.PrivateKey) throws { + let representation = backing.publicKey.rawRepresentation + try self.init(key: ECDSAKey( + parameters: ECDSAKey.Parameters( + x: representation.prefix(representation.count/2).base64URLEncodedString(), + y: representation.suffix(representation.count/2).base64URLEncodedString() + ), + curve: Curve.curve, + privateKey: backing.rawRepresentation.base64URLEncodedString() + )) + } +} + +extension ECDSA.PrivateKey { + public init(backing: Curve.Signing.PrivateKey) throws { + let representation = backing.publicKey.rawRepresentation + try self.init(key: ECDSAKey( + parameters: ECDSAKey.Parameters( + x: representation.prefix(representation.count/2).base64URLEncodedString(), + y: representation.suffix(representation.count/2).base64URLEncodedString() + ), + curve: Curve.curve, + privateKey: backing.rawRepresentation.base64URLEncodedString() + )) + } +} diff --git a/Sources/JWTKit/ECDSA/JWTSigner+ECDSA.swift b/Sources/JWTKit/ECDSA/JWTSigner+ECDSA.swift index 8cf1c278..b42453a8 100644 --- a/Sources/JWTKit/ECDSA/JWTSigner+ECDSA.swift +++ b/Sources/JWTKit/ECDSA/JWTSigner+ECDSA.swift @@ -3,8 +3,10 @@ import class Foundation.JSONEncoder import class Foundation.JSONDecoder extension JWTSigner { + @available(*, deprecated, message: "Unavailable in v5. Please use JWTKeyCollection.add(ecdsa:kid:) instead.") public static func es256(key: ECDSAKey) -> JWTSigner { .es256(key: key, jsonEncoder: nil, jsonDecoder: nil) } + @available(*, deprecated, message: "Unavailable in v5. Please use JWTKeyCollection.add(ecdsa:kid:) instead.") public static func es256(key: ECDSAKey, jsonEncoder: (any JWTJSONEncoder)?, jsonDecoder: (any JWTJSONDecoder)?) -> JWTSigner { .init(algorithm: ECDSASigner( key: key, @@ -13,8 +15,10 @@ extension JWTSigner { ), jsonEncoder: jsonEncoder, jsonDecoder: jsonDecoder) } + @available(*, deprecated, message: "Unavailable in v5. Please use JWTKeyCollection.add(ecdsa:kid:) instead.") public static func es384(key: ECDSAKey) -> JWTSigner { .es384(key: key, jsonEncoder: nil, jsonDecoder: nil) } + @available(*, deprecated, message: "Unavailable in v5. Please use JWTKeyCollection.add(ecdsa:kid:) instead.") public static func es384(key: ECDSAKey, jsonEncoder: (any JWTJSONEncoder)?, jsonDecoder: (any JWTJSONDecoder)?) -> JWTSigner { .init(algorithm: ECDSASigner( key: key, @@ -23,8 +27,10 @@ extension JWTSigner { ), jsonEncoder: jsonEncoder, jsonDecoder: jsonDecoder) } + @available(*, deprecated, message: "Unavailable in v5. Please use JWTKeyCollection.add(ecdsa:kid:) instead.") public static func es512(key: ECDSAKey) -> JWTSigner { .es512(key: key, jsonEncoder: nil, jsonDecoder: nil) } + @available(*, deprecated, message: "Unavailable in v5. Please use JWTKeyCollection.add(ecdsa:kid:) instead.") public static func es512(key: ECDSAKey, jsonEncoder: (any JWTJSONEncoder)?, jsonDecoder: (any JWTJSONDecoder)?) -> JWTSigner { .init(algorithm: ECDSASigner( key: key, @@ -33,3 +39,67 @@ extension JWTSigner { ), jsonEncoder: jsonEncoder, jsonDecoder: jsonDecoder) } } + +extension JWTKeyCollection { + /// Adds an ECDSA key to the collection. + /// + /// Example Usage: + /// ``` + /// let collection = await JWTKeyCollection() + /// .addECDSA(key: myECDSAKey) + /// ``` + /// + /// - Parameters: + /// - key: The ``ECDSAKey`` to be used for signing. This key should be securely stored and not exposed. + /// - kid: An optional ``JWKIdentifier`` (Key ID). If provided, this identifier will be used in the JWT `kid` + /// header field to identify the key. + /// - Returns: The same instance of the collection (`Self`), which allows for method chaining. + @discardableResult + public func add( + ecdsa key: ECDSA.PublicKey, + kid: JWKIdentifier? = nil + ) -> Self { + switch key.curve { + case .p256: + try signers.use(.es256(key: key.key), kid: kid) + case .p384: + try signers.use(.es384(key: key.key), kid: kid) + case .p521: + try signers.use(.es512(key: key.key), kid: kid) + case .ed25519, .ed448, .none: + fatalError("Unsupported ECDSA key curve: \(key.curve?.rawValue ?? ".none")") + } + return self + } + + /// Adds an ECDSA key to the collection. + /// + /// Example Usage: + /// ``` + /// let collection = await JWTKeyCollection() + /// .addECDSA(key: myECDSAKey) + /// ``` + /// + /// - Parameters: + /// - key: The ``ECDSAKey`` to be used for signing. This key should be securely stored and not exposed. + /// - kid: An optional ``JWKIdentifier`` (Key ID). If provided, this identifier will be used in the JWT `kid` + /// header field to identify the key. + /// - Returns: The same instance of the collection (`Self`), which allows for method chaining. + @discardableResult + public func add( + ecdsa key: ECDSA.PrivateKey, + kid: JWKIdentifier? = nil + ) -> Self { + switch key.curve { + case .p256: + try signers.use(.es256(key: key.key), kid: kid) + case .p384: + try signers.use(.es384(key: key.key), kid: kid) + case .p521: + try signers.use(.es512(key: key.key), kid: kid) + case .ed25519, .ed448, .none: + fatalError("Unsupported ECDSA key curve: \(key.curve?.rawValue ?? ".none")") + } + return self + } +} diff --git a/Sources/JWTKit/JWTParser.swift b/Sources/JWTKit/JWTParser.swift index 9fcee763..f8dd339f 100644 --- a/Sources/JWTKit/JWTParser.swift +++ b/Sources/JWTKit/JWTParser.swift @@ -30,6 +30,13 @@ struct JWTParser { .decode(Payload.self, from: .init(self.encodedPayload.base64URLDecodedBytes())) } + func payload(as payload: Payload.Type, jsonDecoder: any JWTJSONDecoder) throws -> Payload + where Payload: AsyncJWTPayload + { + try jsonDecoder + .decode(Payload.self, from: .init(self.encodedPayload.base64URLDecodedBytes())) + } + func verify(using signer: JWTSigner) throws { guard try signer.algorithm.verify(self.signature, signs: self.message) else { throw JWTError.signatureVerifictionFailed diff --git a/Sources/JWTKit/JWTPayload.swift b/Sources/JWTKit/JWTPayload.swift index 4ac553df..82f5ca4e 100644 --- a/Sources/JWTKit/JWTPayload.swift +++ b/Sources/JWTKit/JWTPayload.swift @@ -1,6 +1,17 @@ /// A JWT payload is a Publically Readable set of claims /// Each variable represents a claim. +/// - Warning: Requirements changed in v5 to be async. Please also conform to ``AsyncJWTPayload`` while on v4, and remove the ``AsyncJWTPayload`` conformance once you do update to v5. public protocol JWTPayload: Codable { /// Verifies that the payload's claims are correct or throws an error. func verify(using signer: JWTSigner) throws } + +/// A transitionary protocol with sync and async requirements. +/// +/// This protocol should be dropped once you are finished migrating to v5, as it'll have been renamed back to ``JWTPayload``, but with a single async requirement. In order to support both versions v4 and v5 in a library, do not implement the requirements of ``JWTPayload`` as ``JWTSigner`` is no longer available in v5. +public protocol AsyncJWTPayload: Codable { + func verify(using signer: Algorithm) throws + + /// Verifies that the payload's claims are correct or throws an error. + func verify(using algorithm: Algorithm) async throws +} diff --git a/Sources/JWTKit/JWTSerializer.swift b/Sources/JWTKit/JWTSerializer.swift index 43352a85..a7e79889 100644 --- a/Sources/JWTKit/JWTSerializer.swift +++ b/Sources/JWTKit/JWTSerializer.swift @@ -9,7 +9,7 @@ struct JWTSerializer { cty: String? = nil, jsonEncoder: any JWTJSONEncoder ) throws -> String - where Payload: JWTPayload + where Payload: Encodable { // encode header, copying header struct to mutate alg var header = JWTHeader() diff --git a/Sources/JWTKit/JWTSigner.swift b/Sources/JWTKit/JWTSigner.swift index 59cc66b1..a1fbbddf 100644 --- a/Sources/JWTKit/JWTSigner.swift +++ b/Sources/JWTKit/JWTSigner.swift @@ -19,6 +19,18 @@ public final class JWTSigner { self.jsonDecoder = jsonDecoder } + public func sign( + _ payload: Payload, + typ: String = "JWT", + kid: JWKIdentifier? = nil, + cty: String? = nil + ) throws -> String + where Payload: AsyncJWTPayload + { + try JWTSerializer().sign(payload, using: self, typ: typ, kid: kid, cty: cty, jsonEncoder: self.jsonEncoder ?? .defaultForJWT) + } + + @available(*, deprecated, message: "Please make sure Payload conforms to AsyncJWTPayload instead of JWTPayload before updating to v5.") public func sign( _ payload: Payload, typ: String = "JWT", @@ -30,6 +42,16 @@ public final class JWTSigner { try JWTSerializer().sign(payload, using: self, typ: typ, kid: kid, cty: cty, jsonEncoder: self.jsonEncoder ?? .defaultForJWT) } + public func unverified( + _ token: String, + as payload: Payload.Type = Payload.self + ) throws -> Payload + where Payload: AsyncJWTPayload + { + try self.unverified([UInt8](token.utf8)) + } + + @available(*, deprecated, message: "Please make sure Payload conforms to AsyncJWTPayload instead of JWTPayload before updating to v5.") public func unverified( _ token: String, as payload: Payload.Type = Payload.self @@ -39,6 +61,16 @@ public final class JWTSigner { try self.unverified([UInt8](token.utf8)) } + public func unverified( + _ token: Message, + as payload: Payload.Type = Payload.self + ) throws -> Payload + where Message: DataProtocol, Payload: AsyncJWTPayload + { + try JWTParser(token: token).payload(as: Payload.self, jsonDecoder: self.jsonDecoder ?? .defaultForJWT) + } + + @available(*, deprecated, message: "Please make sure Payload conforms to AsyncJWTPayload instead of JWTPayload before updating to v5.") public func unverified( _ token: Message, as payload: Payload.Type = Payload.self @@ -48,6 +80,16 @@ public final class JWTSigner { try JWTParser(token: token).payload(as: Payload.self, jsonDecoder: self.jsonDecoder ?? .defaultForJWT) } + public func verify( + _ token: String, + as payload: Payload.Type = Payload.self + ) throws -> Payload + where Payload: AsyncJWTPayload + { + try self.verify([UInt8](token.utf8), as: Payload.self) + } + + @available(*, deprecated, message: "Please make sure Payload conforms to AsyncJWTPayload instead of JWTPayload before updating to v5.") public func verify( _ token: String, as payload: Payload.Type = Payload.self @@ -57,6 +99,17 @@ public final class JWTSigner { try self.verify([UInt8](token.utf8), as: Payload.self) } + public func verify( + _ token: Message, + as payload: Payload.Type = Payload.self + ) throws -> Payload + where Message: DataProtocol, Payload: AsyncJWTPayload + { + let parser = try JWTParser(token: token) + return try self.verify(parser: parser) + } + + @available(*, deprecated, message: "Please make sure Payload conforms to AsyncJWTPayload instead of JWTPayload before updating to v5.") public func verify( _ token: Message, as payload: Payload.Type = Payload.self @@ -75,4 +128,22 @@ public final class JWTSigner { try payload.verify(using: self) return payload } + + func verify(parser: JWTParser) throws -> Payload + where Payload: AsyncJWTPayload + { + try parser.verify(using: self) + let payload = try parser.payload(as: Payload.self, jsonDecoder: self.jsonDecoder ?? .defaultForJWT) + try payload.verify(using: self.algorithm) + return payload + } + + func verify(parser: JWTParser) async throws -> Payload + where Payload: AsyncJWTPayload + { + try parser.verify(using: self) + let payload = try parser.payload(as: Payload.self, jsonDecoder: self.jsonDecoder ?? .defaultForJWT) + try await payload.verify(using: self.algorithm) + return payload + } } diff --git a/Sources/JWTKit/JWTSigners.swift b/Sources/JWTKit/JWTSigners.swift index 770fbab9..063dac15 100644 --- a/Sources/JWTKit/JWTSigners.swift +++ b/Sources/JWTKit/JWTSigners.swift @@ -1,6 +1,7 @@ import Foundation /// A collection of signers labeled by `kid`. +@available(*, deprecated, renamed: "JWTKeyCollection", message: "Unavailable in v5. Please use JWTKeyCollection instead.") public final class JWTSigners { /// Internal storage. private enum Signer { @@ -14,6 +15,7 @@ public final class JWTSigners { /// The default JSON encoder. Used as: /// /// - The default for any ``JWTSigner`` which does not specify its own encoder. + @available(*, deprecated, message: "Unavailable in v5.") public let defaultJSONEncoder: any JWTJSONEncoder /// The default JSON decoder. Used for: @@ -22,9 +24,11 @@ public final class JWTSigners { /// - Decoding unverified payloads without a signer (see ``JWTSigners/unverified(_:as:)-3qzpk``). /// - Decoding token headers to determine a key type (see ``JWTSigners/verify(_:as:)-6tee7``). /// - The default for any``JWTSigner`` which does not specify its own encoder. + @available(*, deprecated, message: "Unavailable in v5.") public let defaultJSONDecoder: any JWTJSONDecoder /// Create a new ``JWTSigners``. + @available(*, deprecated, message: "Unavailable in v5. Please use JWTKeyCollection() instead.") public init() { self.storage = [:] self.defaultJSONEncoder = .defaultForJWT @@ -32,6 +36,7 @@ public final class JWTSigners { } /// Create a new ``JWTSigners`` with specific JSON coders. + @available(*, deprecated, message: "Unavailable in v5.") public init(defaultJSONEncoder: any JWTJSONEncoder, defaultJSONDecoder: any JWTJSONDecoder) { self.storage = [:] self.defaultJSONEncoder = defaultJSONEncoder @@ -39,6 +44,7 @@ public final class JWTSigners { } /// Adds a new signer. + @available(*, deprecated, message: "Unavailable in v5. Please use JWTKeyCollection.add(...) instead.") public func use( _ signer: JWTSigner, kid: JWKIdentifier? = nil, @@ -59,17 +65,20 @@ public final class JWTSigners { /// Adds a `JWKS` (JSON Web Key Set) to this signers collection /// by first decoding the JSON string. + @available(*, deprecated, message: "Unavailable in v5. Please use JWTKeyCollection.add(jwksJSON:) instead.") public func use(jwksJSON json: String) throws { let jwks = try self.defaultJSONDecoder.decode(JWKS.self, from: Data(json.utf8)) try self.use(jwks: jwks) } /// Adds a `JWKS` (JSON Web Key Set) to this signers collection. + @available(*, deprecated, message: "Unavailable in v5. Please use JWTKeyCollection.add(jwks:) instead.") public func use(jwks: JWKS) throws { try jwks.keys.forEach { try self.use(jwk: $0) } } /// Adds a `JWK` (JSON Web Key) to this signers collection. + @available(*, deprecated, message: "Unavailable in v5. Please use JWTKeyCollection.add(jwk:isDefault:) instead.") public func use( jwk: JWK, isDefault: Bool? = nil @@ -87,6 +96,7 @@ public final class JWTSigners { } /// Gets a signer for the supplied `kid`, if one exists. + @available(*, deprecated, message: "Unavailable in v5. Please use JWTKeyCollection.getKey(for:alg:) instead.") public func get(kid: JWKIdentifier? = nil, alg: String? = nil) -> JWTSigner? { let signer: Signer if let kid = kid, let stored = self.storage[kid] { @@ -104,6 +114,7 @@ public final class JWTSigners { } } + @available(*, deprecated, message: "Unavailable in v5. Please use JWTKeyCollection.getKey(for:alg:) instead.") public func require(kid: JWKIdentifier? = nil, alg: String? = nil) throws -> JWTSigner { guard let signer = self.get(kid: kid, alg: alg) else { if let kid = kid { @@ -115,6 +126,7 @@ public final class JWTSigners { return signer } + @available(*, deprecated, message: "Unavailable in v5. Please use JWTKeyCollection.unverified(_:as:) instead.") public func unverified( _ token: String, as payload: Payload.Type = Payload.self @@ -124,6 +136,7 @@ public final class JWTSigners { try self.unverified([UInt8](token.utf8)) } + @available(*, deprecated, message: "Unavailable in v5. Please use JWTKeyCollection.unverified(_:as:) instead.") public func unverified( _ token: Message, as payload: Payload.Type = Payload.self @@ -133,7 +146,7 @@ public final class JWTSigners { try JWTParser(token: token).payload(as: Payload.self, jsonDecoder: self.defaultJSONDecoder) } - + @available(*, deprecated, message: "Unavailable in v5. Please use JWTKeyCollection.verify(_:as:) instead.") public func verify( _ token: String, as payload: Payload.Type = Payload.self @@ -143,6 +156,7 @@ public final class JWTSigners { try self.verify([UInt8](token.utf8), as: Payload.self) } + @available(*, deprecated, message: "Unavailable in v5. Please use JWTKeyCollection.verify(_:as:) instead.") public func verify( _ token: Message, as payload: Payload.Type = Payload.self @@ -155,6 +169,7 @@ public final class JWTSigners { return try signer.verify(parser: parser) } + @available(*, deprecated, message: "Unavailable in v5. Please use JWTKeyCollection.sign(_:kid:) instead.") public func sign( _ payload: Payload, typ: String = "JWT", @@ -280,3 +295,231 @@ private struct JWKSigner { } } } + +/// JWTKeyCollection was introduced in v5 and replaces ``JWTSigners``. +/// +/// - Note: Please migrate over to ``JWTKeyCollection`` before updating to v5, though if you plan on remaining on v4, ``JWTSigners`` can continue to be used. +public actor JWTKeyCollection { + var signers = JWTSigners() + + /// Creates a new empty Signers collection. + public init() {} + + /// Adds a `JWKS` (JSON Web Key Set) to the collection by decoding a JSON string. + /// + /// - Parameter json: A JSON string representing a JWKS. + /// - Throws: An error if the JSON string cannot be decoded into a `JWKS` instance. + /// - Returns: Self for chaining. + @discardableResult + public func add(jwksJSON json: String) throws -> Self { + try signers.use(jwksJSON: json) + return self + } + + /// Adds a `JWKS` (JSON Web Key Set) directly to the collection. + /// + /// - Parameter jwks: A `JWKS` instance. + /// - Throws: An error if the JWKS cannot be added. + /// - Returns: Self for chaining. + @discardableResult + public func add(jwks: JWKS) throws -> Self { + try signers.use(jwks: jwks) + return self + } + + /// Adds a single `JWK` (JSON Web Key) to the collection. + /// + /// - Parameters: + /// - jwk: A `JWK` instance to be added. + /// - isDefault: An optional Boolean indicating whether this key should be the default key. + /// - Throws: ``JWTError/invalidJWK`` if the JWK cannot be added due to missing key identifier. + /// - Returns: Self for chaining. + @discardableResult + public func add(jwk: JWK, isDefault: Bool? = nil) throws -> Self { + try signers.use(jwk: jwk, isDefault: isDefault) + return self + } + + /// Retrieves the key associated with the provided key identifier (KID) and algorithm (ALG), if available. + /// - Parameters: + /// - kid: An optional ``JWKIdentifier``. If not provided, the default signer is returned. + /// - alg: An optional algorithm identifier. + /// - Returns: A ``JWTKey`` if one is found; otherwise, `nil`. + /// - Throws: ``JWTError/generic`` if the algorithm cannot be retrieved. + public func getKey(for kid: JWKIdentifier? = nil, alg: String? = nil) async throws -> any JWTAlgorithm { + try signers.require(kid: kid, alg: alg).algorithm + } + + /// Decodes an unverified JWT payload. + /// + /// This method does not verify the signature of the JWT and should be used with caution. + /// + /// - Parameters: + /// - token: A JWT token string. + /// - Throws: An error if the payload cannot be decoded. + /// - Returns: The decoded payload of the specified type. + @available(*, deprecated, message: "Please make sure Payload conforms to AsyncJWTPayload instead of JWTPayload before updating to v5.") + public func unverified( + _ token: String, + as _: Payload.Type = Payload.self + ) throws -> Payload + where Payload: JWTPayload { + try unverified(Array(token.utf8), as: Payload.self) + } + + /// Decodes an unverified JWT payload. + /// + /// This method does not verify the signature of the JWT and should be used with caution. + /// + /// - Parameters: + /// - token: A JWT token. + /// - Throws: An error if the payload cannot be decoded. + /// - Returns: The decoded payload of the specified type. + @available(*, deprecated, message: "Please make sure Payload conforms to AsyncJWTPayload instead of JWTPayload before updating to v5.") + public func unverified( + _ token: Data, + as _: Payload.Type = Payload.self + ) throws -> Payload + where Payload: JWTPayload { + try JWTParser(token: token).payload(as: Payload.self, jsonDecoder: signers.defaultJSONDecoder) + } + + /// Decodes an unverified JWT payload. + /// + /// This method does not verify the signature of the JWT and should be used with caution. + /// + /// - Parameters: + /// - token: A JWT token string. + /// - Throws: An error if the payload cannot be decoded. + /// - Returns: The decoded payload of the specified type. + public func unverified( + _ token: String, + as _: Payload.Type = Payload.self + ) throws -> Payload + where Payload: AsyncJWTPayload { + try unverified(Array(token.utf8), as: Payload.self) + } + + /// Decodes an unverified JWT payload. + /// + /// This method does not verify the signature of the JWT and should be used with caution. + /// + /// - Parameters: + /// - token: A JWT token. + /// - Throws: An error if the payload cannot be decoded. + /// - Returns: The decoded payload of the specified type. + public func unverified( + _ token: Data, + as _: Payload.Type = Payload.self + ) throws -> Payload + where Payload: AsyncJWTPayload { + try JWTParser(token: token).payload(as: Payload.self, jsonDecoder: signers.defaultJSONDecoder) + } + + /// Verifies and decodes a JWT token to extract the payload. + /// + /// - Parameters: + /// - token: A JWT token string. + /// - as: The type of payload to decode. + /// - iteratingKeys: Whether to try verifying the token with all keys in the collection. + /// - Throws: An error if the token cannot be verified or decoded. + /// - Returns: The verified and decoded payload of the specified type. + @available(*, deprecated, message: "Please make sure Payload conforms to AsyncJWTPayload instead of JWTPayload before updating to v5.") + public func verify( + _ token: String, + as _: Payload.Type = Payload.self, + iteratingKeys: Bool = false + ) async throws -> Payload + where Payload: JWTPayload { + try signers.verify(token, as: Payload.self) + } + + /// Verifies and decodes a JWT token to extract the payload. + /// + /// - Parameters: + /// - token: A JWT token. + /// - as: The type of payload to decode. + /// - iteratingKeys: Whether to try verifying the token with all keys in the collection. + /// - Throws: An error if the token cannot be verified or decoded. + /// - Returns: The verified and decoded payload of the specified type. + @available(*, deprecated, message: "Please make sure Payload conforms to AsyncJWTPayload instead of JWTPayload before updating to v5.") + public func verify( + _ token: Data, + as _: Payload.Type = Payload.self + ) async throws -> Payload + where Payload: JWTPayload { + try signers.verify(token, as: Payload.self) + } + + /// Verifies and decodes a JWT token to extract the payload. + /// + /// - Parameters: + /// - token: A JWT token string. + /// - as: The type of payload to decode. + /// - iteratingKeys: Whether to try verifying the token with all keys in the collection. + /// - Throws: An error if the token cannot be verified or decoded. + /// - Returns: The verified and decoded payload of the specified type. + public func verify( + _ token: String, + as _: Payload.Type = Payload.self, + iteratingKeys: Bool = false + ) async throws -> Payload + where Payload: AsyncJWTPayload { + try await verify(Array(token.utf8), as: Payload.self) + } + + /// Verifies and decodes a JWT token to extract the payload. + /// + /// - Parameters: + /// - token: A JWT token. + /// - as: The type of payload to decode. + /// - iteratingKeys: Whether to try verifying the token with all keys in the collection. + /// - Throws: An error if the token cannot be verified or decoded. + /// - Returns: The verified and decoded payload of the specified type. + public func verify( + _ token: Data, + as _: Payload.Type = Payload.self + ) async throws -> Payload + where Payload: AsyncJWTPayload { + let parser = try JWTParser(token: token) + let header = try parser.header(jsonDecoder: signers.defaultJSONDecoder) + let signer = try signers.require(kid: header.kid, alg: header.alg) + return try await signer.verify(parser: parser) + } + + /// Signs a JWT payload and returns the JWT string. + /// + /// - Parameters: + /// - payload: The payload to sign. + /// - kid: An optional key identifier to specify the signer. + /// If not provided, the header is checked for a KID, + /// and if that is not provided, the default signer is used. + /// - header: An optional header to include in the JWT. + /// - Throws: An error if the payload cannot be signed. + /// - Returns: A signed JWT token string. + @available(*, deprecated, message: "Please make sure Payload conforms to AsyncJWTPayload instead of JWTPayload before updating to v5.") + public func sign( + _ payload: Payload, + kid: JWKIdentifier? = nil + ) async throws -> String { + try signers.sign(payload, kid: kid) + } + + /// Signs a JWT payload and returns the JWT string. + /// + /// - Parameters: + /// - payload: The payload to sign. + /// - kid: An optional key identifier to specify the signer. + /// If not provided, the header is checked for a KID, + /// and if that is not provided, the default signer is used. + /// - header: An optional header to include in the JWT. + /// - Throws: An error if the payload cannot be signed. + /// - Returns: A signed JWT token string. + public func sign( + _ payload: Payload, + kid: JWKIdentifier? = nil + ) async throws -> String { + let signer = try signers.require(kid: kid) + return try signer.sign(payload, kid: kid) + } +} diff --git a/Sources/JWTKit/Utilities/Base64URL.swift b/Sources/JWTKit/Utilities/Base64URL.swift index db15c19c..56cfc2fa 100644 --- a/Sources/JWTKit/Utilities/Base64URL.swift +++ b/Sources/JWTKit/Utilities/Base64URL.swift @@ -8,6 +8,10 @@ extension DataProtocol { func base64URLEncodedBytes() -> [UInt8] { return Data(self.copyBytes()).base64EncodedData().base64URLEscaped().copyBytes() } + + func base64URLEncodedString() -> String { + String(decoding: Data(self.copyBytes()).base64EncodedData().base64URLEscaped(), as: UTF8.self) + } } /// MARK: Data Escape diff --git a/Tests/JWTKitTests/JWTMigrationTests.swift b/Tests/JWTKitTests/JWTMigrationTests.swift new file mode 100644 index 00000000..7ac376d3 --- /dev/null +++ b/Tests/JWTKitTests/JWTMigrationTests.swift @@ -0,0 +1,53 @@ +import XCTest +import Crypto +#if os(Linux) +import FoundationNetworking +#endif +@testable import JWTKit + +class JWTKitMigrationTests: XCTestCase { + func testVerifyingCryptoKey() async throws { + struct Foo: AsyncJWTPayload { + var bar: Int + func verify(using signer: Algorithm) async throws { } + func verify(using signer: Algorithm) throws { } + } + + // ecdsa key + let x = "0tu_H2ShuV8RIgoOxFneTdxmQQYsSk5LdCPuEIBXT-hHd0ufc_OwjEbqilsYnTdm" + let y = "RWRZz-tP83N0CGwroGyFVgH3PYAO6Oewpu4Xf6EXCp4-sU8uWegwjd72sBK6axj7" + + let privateKey = "k-1LAHQRSSMcyaouYK0YOzRbUKj6ISnvihO2XdLQZHQgMt9BkuCT0-539FSHmJxg" + + let cryptoKey = try P384.Signing.PrivateKey(rawRepresentation: Data(privateKey.utf8).base64URLDecodedBytes()) + let kwtKey = try ES384PrivateKey(backing: cryptoKey) + + XCTAssertEqual(kwtKey.parameters?.x, x) + XCTAssertEqual(kwtKey.parameters?.y, y) + + let privateSigners = JWTKeyCollection() + await privateSigners.add(ecdsa: kwtKey) + + let jwt = try await privateSigners.sign(Foo(bar: 42), kid: "vapor") + + // verify using jwks without alg + let jwksString = """ + { + "keys": [ + { + "kty": "EC", + "use": "sig", + "kid": "vapor", + "x": "\(x)", + "y": "\(y)" + } + ] + } + """ + + let signers = JWTKeyCollection() + try await signers.add(jwksJSON: jwksString) + let foo = try await signers.verify(jwt, as: Foo.self) + XCTAssertEqual(foo.bar, 42) + } +}