diff --git a/README.md b/README.md index 3ec21e7..5d110bb 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ This library provides comprehensive support for the Jose suite of standards, inc | iat |:white_check_mark:| | typ |:white_check_mark:| | cty |:white_check_mark:| +| DSL Claims Builder|:white_check_mark:| @@ -386,8 +387,12 @@ JWT is a compact, URL-safe means of representing claims to be transferred betwee 3. **Nested JWT (JWS + JWE)**: - Implements Nested JWTs where a JWT is signed and then encrypted, providing both the benefits of JWS and JWE. - Ensures that a token is first authenticated (JWS) and then secured for privacy (JWE). + +4. **Domain-specific language (DSL) for Claim Creation**: + - Allows for a more declarative approach to creating claims using a domain-specific language (DSL). + - Facilitates the creation of both standard and custom claims in a readable and structured manner. -4. **Claim Validation**: +5. **Claim Validation**: - Offers extensive capabilities to validate JWT claims. - Includes standard claims like issuer (`iss`), subject (`sub`), audience (`aud`), expiration (`exp`), not before (`nbf`), and issued at (`iat`). - Custom claim validation to meet specific security requirements. @@ -440,6 +445,74 @@ let verifiedJWT = try JWT.verify(jwtString: jwtString, recipie let verifiedPayload = verifiedJWT.payload ``` +- DSL for Creating Claims + - Standard Claims on signing a JWT + + ```swift + let key = JWK.testingES256Pair + + let jwt = try JWT.signed( + payload: { + IssuerClaim(value: "testIssuer") + SubjectClaim(value: "testSubject") + ExpirationTimeClaim(value: Date()) + IssuedAtClaim(value: Date()) + NotBeforeClaim(value: Date()) + JWTIdentifierClaim(value: "ThisIdentifier") + AudienceClaim(value: "testAud") + }, + protectedHeader: DefaultJWSHeaderImpl(algorithm: .ES256), + key: key + ).jwtString + ``` + + - Custom Claims + + ```swift + let jsonClaimsObject = JWTClaimsBuilder.build { + StringClaim(key: "testStr1", value: "value1") + NumberClaim(key: "testN1", value: 0) + NumberClaim(key: "testN2", value: 1.1) + NumberClaim(key: "testN3", value: Double(1.233232)) + BoolClaim(key: "testBool1", value: true) + ArrayClaim(key: "testArray") { + ArrayElementClaim.string("valueArray1") + ArrayElementClaim.string("valueArray2") + ArrayElementClaim.bool(true) + ArrayElementClaim.array { + ArrayElementClaim.string("nestedNestedArray1") + } + ArrayElementClaim.object { + StringClaim(key: "nestedNestedObject", value: "nestedNestedValue") + } + } + ObjectClaim(key: "testObject") { + StringClaim(key: "testDicStr1", value: "valueDic1") + } + } + + // Output + // { + // "testBool1":true, + // "testArray":[ + // "valueArray1", + // "valueArray2", + // true, + // ["nestedNestedArray1"], + // { + // "nestedNestedObject":"nestedNestedValue" + // } + // ], + // "testObject":{ + // "testDicStr1":"valueDic1" + // }, + // "testN1":0, + // "testStr1":"value1", + // "testN3":1.233232, + // "testN2":1.1 + // } + ``` + ### JWA (JSON Web Algorithms) JWA specifies cryptographic algorithms used in the context of Jose to perform digital signing and content encryption, as detailed in [RFC 7518](https://datatracker.ietf.org/doc/html/rfc7518). It includes standards for various types of algorithms like RSA, AES, HMAC, and more. diff --git a/Sources/JSONWebToken/Claims/ArrayClaim.swift b/Sources/JSONWebToken/Claims/ArrayClaim.swift new file mode 100644 index 0000000..259c63e --- /dev/null +++ b/Sources/JSONWebToken/Claims/ArrayClaim.swift @@ -0,0 +1,103 @@ +/* + * Copyright 2024 Gonçalo Frade + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// Represents an array claim within a JWT. +public struct ArrayClaim: Claim { + public var value: ClaimElement + + /// A result builder for constructing array claims. + @resultBuilder + public struct ArrayClaimBuilder { + /// Builds an array of `ArrayElementClaim` from the provided components. + /// - Parameter components: The array element claims to include in the array. + /// - Returns: An array of `ArrayElementClaim`. + public static func buildBlock(_ components: ArrayElementClaim...) -> [ArrayElementClaim] { + components + } + + /// Builds an array of `ArrayElementClaim` from the provided components. + /// - Parameter components: The array element claims to include in the array. + /// - Returns: An array of `ArrayElementClaim`. + public static func buildBlock(_ components: Claim...) -> [Claim] { + components + } + + /// Builds an array of `StringClaim` from the provided components. + /// - Parameter components: The string claims to include in the array. + /// - Returns: An array of `StringClaim`. + public static func buildBlock(_ components: StringClaim...) -> [StringClaim] { + components + } + } + + /// Initializes an `ArrayClaim` with a key and a builder for the array elements. + /// - Parameters: + /// - key: The key for the claim. + /// - claims: A closure that returns an array of `ArrayElementClaim` using the result builder. + public init(key: String, @ArrayClaimBuilder claims: () -> [ArrayElementClaim]) { + self.value = .init(key: key, element: .array(claims().map(\.value))) + } + + /// Initializes an `ArrayClaim` with a key and a builder for the array elements. + /// - Parameters: + /// - key: The key for the claim. + /// - claims: A closure that returns an array of `Claim` using the result builder. + public init(key: String, @ArrayClaimBuilder claims: () -> [Claim]) { + self.value = .init(key: key, element: .array(claims().map(\.value))) + } +} + +/// Represents an element within an array claim. +public struct ArrayElementClaim { + let value: ClaimElement + + /// Creates an `ArrayElementClaim` with a string value. + /// - Parameter str: The string value for the claim. + /// - Returns: An `ArrayElementClaim` containing the string value. + public static func string(_ str: String) -> ArrayElementClaim { + .init(value: StringClaim(key: "", value: str).value) + } + + /// Creates an `ArrayElementClaim` with a numeric value. + /// - Parameter number: The numeric value for the claim. + /// - Returns: An `ArrayElementClaim` containing the numeric value. + public static func number(_ number: N) -> ArrayElementClaim { + .init(value: NumberClaim(key: "", value: number).value) + } + + /// Creates an `ArrayElementClaim` with a boolean value. + /// - Parameter boolean: The boolean value for the claim. + /// - Returns: An `ArrayElementClaim` containing the boolean value. + public static func bool(_ boolean: Bool) -> ArrayElementClaim { + .init(value: BoolClaim(key: "", value: boolean).value) + } + + /// Creates an `ArrayElementClaim` with an array of claims. + /// - Parameter claims: A closure that returns an array of `ArrayElementClaim` using the result builder. + /// - Returns: An `ArrayElementClaim` containing the array of claims. + public static func array(@ArrayClaim.ArrayClaimBuilder claims: () -> [ArrayElementClaim]) -> ArrayElementClaim { + .init(value: ArrayClaim(key: "", claims: claims).value) + } + + /// Creates an `ArrayElementClaim` with an object of claims. + /// - Parameter claims: A closure that returns an array of `Claim` using the result builder. + /// - Returns: An `ArrayElementClaim` containing the object of claims. + public static func object(@ObjectClaim.ObjectClaimBuilder claims: () -> [Claim]) -> ArrayElementClaim { + .init(value: ObjectClaim(key: "", claims: claims).value) + } +} diff --git a/Sources/JSONWebToken/Claims/AudienceClaim.swift b/Sources/JSONWebToken/Claims/AudienceClaim.swift new file mode 100644 index 0000000..69978f7 --- /dev/null +++ b/Sources/JSONWebToken/Claims/AudienceClaim.swift @@ -0,0 +1,37 @@ +/* + * Copyright 2024 Gonçalo Frade + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// A type alias for `AudienceClaim`. +typealias AudClaim = AudienceClaim + +/// Represents the "aud" (audience) claim in a JWT. +public struct AudienceClaim: JWTRegisteredClaim { + public var value: ClaimElement + + /// Initializes an `AudienceClaim` with a string value. + /// - Parameter value: The audience value for the claim. + public init(value: String) { + self.value = ClaimElement(key: "aud", element: .codable(value)) + } + + /// Initializes an `AudienceClaim` with an array of audience values using a result builder. + /// - Parameter claims: A closure that returns an array of `StringClaim` using the result builder. + init(@ArrayClaim.ArrayClaimBuilder claims: () -> [StringClaim]) { + self.value = .init(key: "aud", element: .array(claims().map(\.value))) + } +} diff --git a/Sources/JSONWebToken/Claims/BoolClaim.swift b/Sources/JSONWebToken/Claims/BoolClaim.swift new file mode 100644 index 0000000..e569104 --- /dev/null +++ b/Sources/JSONWebToken/Claims/BoolClaim.swift @@ -0,0 +1,30 @@ +/* + * Copyright 2024 Gonçalo Frade + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// Represents a boolean claim within a JWT. +public struct BoolClaim: Claim { + public var value: ClaimElement + + /// Initializes a `BoolClaim` with a key and a boolean value. + /// - Parameters: + /// - key: The key for the claim. + /// - value: The boolean value for the claim. + public init(key: String, value: Bool) { + self.value = ClaimElement(key: key, element: .codable(value)) + } +} diff --git a/Sources/JSONWebToken/Claims/Claims+Codable.swift b/Sources/JSONWebToken/Claims/Claims+Codable.swift new file mode 100644 index 0000000..2a4ef4a --- /dev/null +++ b/Sources/JSONWebToken/Claims/Claims+Codable.swift @@ -0,0 +1,88 @@ +/* + * Copyright 2024 Gonçalo Frade + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +extension ClaimElement: Encodable { + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: DynamicCodingKey.self) + switch element { + case .codable(let obj): + try container.encode(obj, forKey: .init(stringValue: key)!) + case .element(let element): + try container.encode(element, forKey: .init(stringValue: key)!) + case .array(let elements): + var nested = container.nestedUnkeyedContainer(forKey: .init(stringValue: key)!) + try encodeArrayElements(container: &nested, elements: elements) + case .object(let elements): + var nested: KeyedEncodingContainer + if key.isEmpty { + nested = container + } else { + nested = container.nestedContainer(keyedBy: DynamicCodingKey.self, forKey: .init(stringValue: key)!) + } + + try encodeObjectElement(container: &nested, elements: elements) + } + } + + private func encodeArrayElements(container: inout UnkeyedEncodingContainer, elements: [ClaimElement]) throws { + try elements.forEach { + switch $0.element { + case .codable(let obj): + try container.encode(obj) + case .element(let element): + try container.encode(element) + case .array(let elements): + var nested = container.nestedUnkeyedContainer() + try encodeArrayElements(container: &nested, elements: elements) + case .object(let elements): + var nested = container.nestedContainer(keyedBy: DynamicCodingKey.self) + try encodeObjectElement(container: &nested, elements: elements) + } + } + } + + private func encodeObjectElement(container: inout KeyedEncodingContainer, elements: [ClaimElement]) throws { + try elements.forEach { + switch $0.element { + case .codable(let obj): + try container.encode(obj, forKey: .init(stringValue: $0.key)!) + case .element(let element): + try container.encode(element, forKey: .init(stringValue: $0.key)!) + case .array(let elements): + var nested = container.nestedUnkeyedContainer(forKey: .init(stringValue: $0.key)!) + try encodeArrayElements(container: &nested, elements: elements) + case .object(let elements): + var nested = container.nestedContainer(keyedBy: DynamicCodingKey.self, forKey: .init(stringValue: $0.key)!) + try encodeObjectElement(container: &nested, elements: elements) + } + } + } +} + +struct DynamicCodingKey: CodingKey { + var stringValue: String + init?(stringValue: String) { + self.stringValue = stringValue + } + + var intValue: Int? + init?(intValue: Int) { + self.stringValue = "\(intValue)" + self.intValue = intValue + } +} diff --git a/Sources/JSONWebToken/Claims/Claims.swift b/Sources/JSONWebToken/Claims/Claims.swift new file mode 100644 index 0000000..533371d --- /dev/null +++ b/Sources/JSONWebToken/Claims/Claims.swift @@ -0,0 +1,54 @@ +/* + * Copyright 2024 Gonçalo Frade + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +indirect enum Value { + case codable(Codable) + case element(ClaimElement) + case array([ClaimElement]) + case object([ClaimElement]) +} + +/// Represents a claim element used within JWTs. +public struct ClaimElement { + var key: String + var element: Value + + init(key: String, element: Value) { + self.key = key + self.element = element + } + + /// Initializes a `ClaimElement` with a codable value. + /// - Parameters: + /// - key: The key for the claim. + /// - value: The codable value of the claim. + public init(key: String, value: C) { + self.key = key + self.element = .codable(value) + } +} + +/// Protocol representing a claim within a JWT. +public protocol Claim { + var value: ClaimElement { get } +} + +/// Protocol representing a registered claim within a JWT. +public protocol JWTRegisteredClaim: Claim { + var value: ClaimElement { get } +} diff --git a/Sources/JSONWebToken/Claims/DateClaim.swift b/Sources/JSONWebToken/Claims/DateClaim.swift new file mode 100644 index 0000000..6ba952f --- /dev/null +++ b/Sources/JSONWebToken/Claims/DateClaim.swift @@ -0,0 +1,30 @@ +/* + * Copyright 2024 Gonçalo Frade + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// Represents a date claim within a JWT. +public struct DateClaim: Claim { + public var value: ClaimElement + + /// Initializes a `DateClaim` with a key and a date value. + /// - Parameters: + /// - key: The key for the claim. + /// - value: The date value for the claim. + public init(key: String, value: Date) { + self.value = ClaimElement(key: key, element: .codable(value)) + } +} diff --git a/Sources/JSONWebToken/Claims/ExpirationTimeClaim.swift b/Sources/JSONWebToken/Claims/ExpirationTimeClaim.swift new file mode 100644 index 0000000..5f16e9f --- /dev/null +++ b/Sources/JSONWebToken/Claims/ExpirationTimeClaim.swift @@ -0,0 +1,31 @@ +/* + * Copyright 2024 Gonçalo Frade + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// A type alias for `ExpirationTimeClaim`. +typealias ExpClaim = ExpirationTimeClaim + +/// Represents the "exp" (expiration time) claim in a JWT. +public struct ExpirationTimeClaim: JWTRegisteredClaim { + public var value: ClaimElement + + /// Initializes an `ExpirationTimeClaim` with a date value. + /// - Parameter value: The expiration date value for the claim. + public init(value: Date) { + self.value = ClaimElement(key: "exp", element: .codable(value)) + } +} diff --git a/Sources/JSONWebToken/Claims/IssuedAtClaim.swift b/Sources/JSONWebToken/Claims/IssuedAtClaim.swift new file mode 100644 index 0000000..58ed626 --- /dev/null +++ b/Sources/JSONWebToken/Claims/IssuedAtClaim.swift @@ -0,0 +1,31 @@ +/* + * Copyright 2024 Gonçalo Frade + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// A type alias for `IssuedAtClaim`. +typealias IatClaim = IssuedAtClaim + +/// Represents the "iat" (issued at) claim in a JWT. +public struct IssuedAtClaim: JWTRegisteredClaim { + public var value: ClaimElement + + /// Initializes an `IssuedAtClaim` with a date value. + /// - Parameter value: The issued at date value for the claim. + public init(value: Date) { + self.value = ClaimElement(key: "iat", element: .codable(value)) + } +} diff --git a/Sources/JSONWebToken/Claims/IssuerClaim.swift b/Sources/JSONWebToken/Claims/IssuerClaim.swift new file mode 100644 index 0000000..ff32c3c --- /dev/null +++ b/Sources/JSONWebToken/Claims/IssuerClaim.swift @@ -0,0 +1,31 @@ +/* + * Copyright 2024 Gonçalo Frade + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// A type alias for `IssuerClaim`. +typealias IssClaim = IssuerClaim + +/// Represents the "iss" (issuer) claim in a JWT. +public struct IssuerClaim: JWTRegisteredClaim { + public var value: ClaimElement + + /// Initializes an `IssuerClaim` with a string value. + /// - Parameter value: The issuer value for the claim. + public init(value: String) { + self.value = ClaimElement(key: "iss", element: .codable(value)) + } +} diff --git a/Sources/JSONWebToken/Claims/JWTClaimsBuilder.swift b/Sources/JSONWebToken/Claims/JWTClaimsBuilder.swift new file mode 100644 index 0000000..dff48b6 --- /dev/null +++ b/Sources/JSONWebToken/Claims/JWTClaimsBuilder.swift @@ -0,0 +1,88 @@ +/* + * Copyright 2024 Gonçalo Frade + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// A result builder for constructing JWT claims. +@resultBuilder +public struct JWTClaimsBuilder { + /// Builds a claim from the provided components. + /// - Parameter components: The claims to include. + /// - Returns: An `ObjectClaim` containing the provided claims. + public static func buildBlock(_ components: Claim...) -> Claim { + ObjectClaim(root: true, claims: components.map(\.value)) + } + + /// Builds a claim using a closure with the result builder. + /// - Parameter builder: A closure that returns a claim. + /// - Returns: A claim built by the closure. + /// - Throws: Rethrows any error thrown within the builder closure. + public static func build(@JWTClaimsBuilder builder: () throws -> Claim) rethrows -> Claim { + try builder() + } +} + +extension Value { + func getValue() -> T? { + switch self { + case .codable(let value): + return value as? T + default: + return nil + } + } +} + +extension ObjectClaim: JWTRegisteredFieldsClaims { + var objectClaims: [ClaimElement] { + switch value.element { + case .object(let array): + return array + default: + return [] + } + } + + public var iss: String? { + objectClaims.first { $0.key == "iss" }?.element.getValue() + } + + public var sub: String? { + objectClaims.first { $0.key == "sub" }?.element.getValue() + } + + public var aud: [String]? { + objectClaims.first { $0.key == "aud" }?.element.getValue() + } + + public var exp: Date? { + objectClaims.first { $0.key == "exp" }?.element.getValue() + } + + public var nbf: Date? { + objectClaims.first { $0.key == "nbf" }?.element.getValue() + } + + public var iat: Date? { + objectClaims.first { $0.key == "iat" }?.element.getValue() + } + + public var jti: String? { + objectClaims.first { $0.key == "jti" }?.element.getValue() + } + + public func validateExtraClaims() throws {} +} diff --git a/Sources/JSONWebToken/Claims/JWTIdentifierClaim.swift b/Sources/JSONWebToken/Claims/JWTIdentifierClaim.swift new file mode 100644 index 0000000..c0192ff --- /dev/null +++ b/Sources/JSONWebToken/Claims/JWTIdentifierClaim.swift @@ -0,0 +1,31 @@ +/* + * Copyright 2024 Gonçalo Frade + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// A type alias for `JWTIdentifierClaim`. +typealias JtiClaim = JWTIdentifierClaim + +/// Represents the "jti" (JWT ID) claim in a JWT. +public struct JWTIdentifierClaim: JWTRegisteredClaim { + public var value: ClaimElement + + /// Initializes a `JWTIdentifierClaim` with a string value. + /// - Parameter value: The JWT ID value for the claim. + public init(value: String) { + self.value = ClaimElement(key: "jti", element: .codable(value)) + } +} diff --git a/Sources/JSONWebToken/Claims/NotBeforeClaim.swift b/Sources/JSONWebToken/Claims/NotBeforeClaim.swift new file mode 100644 index 0000000..0d609d2 --- /dev/null +++ b/Sources/JSONWebToken/Claims/NotBeforeClaim.swift @@ -0,0 +1,31 @@ +/* + * Copyright 2024 Gonçalo Frade + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// A type alias for `NotBeforeClaim`. +typealias NbfClaim = NotBeforeClaim + +/// Represents the "nbf" (not before) claim in a JWT. +public struct NotBeforeClaim: JWTRegisteredClaim { + public var value: ClaimElement + + /// Initializes a `NotBeforeClaim` with a date value. + /// - Parameter value: The not before date value for the claim. + public init(value: Date) { + self.value = ClaimElement(key: "nbf", element: .codable(value)) + } +} diff --git a/Sources/JSONWebToken/Claims/NumericClaim.swift b/Sources/JSONWebToken/Claims/NumericClaim.swift new file mode 100644 index 0000000..0f8a270 --- /dev/null +++ b/Sources/JSONWebToken/Claims/NumericClaim.swift @@ -0,0 +1,30 @@ +/* + * Copyright 2024 Gonçalo Frade + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// Represents a numeric claim within a JWT. +public struct NumberClaim: Claim { + public var value: ClaimElement + + /// Initializes a `NumberClaim` with a key and a numeric value. + /// - Parameters: + /// - key: The key for the claim. + /// - value: The numeric value for the claim. + public init(key: String, value: N) { + self.value = ClaimElement(key: key, element: .codable(value)) + } +} diff --git a/Sources/JSONWebToken/Claims/ObjectClaim.swift b/Sources/JSONWebToken/Claims/ObjectClaim.swift new file mode 100644 index 0000000..57a7059 --- /dev/null +++ b/Sources/JSONWebToken/Claims/ObjectClaim.swift @@ -0,0 +1,53 @@ +/* + * Copyright 2024 Gonçalo Frade + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// Represents an object claim within a JWT. +public struct ObjectClaim: Claim { + let isRoot: Bool + public var value: ClaimElement + + /// A result builder for constructing object claims. + @resultBuilder + public struct ObjectClaimBuilder { + /// Builds an array of `Claim` from the provided components. + /// - Parameter components: The claims to include in the object. + /// - Returns: An array of `Claim`. + public static func buildBlock(_ components: Claim...) -> [Claim] { + components + } + } + + /// Initializes an `ObjectClaim` with a key and a builder for the object elements. + /// - Parameters: + /// - key: The key for the claim. + /// - claims: A closure that returns an array of `Claim` using the result builder. + public init(key: String, @ObjectClaimBuilder claims: () -> [Claim]) { + self.isRoot = false + self.value = .init(key: key, element: .object(claims().map(\.value))) + } + + init(key: String, claims: [ClaimElement]) { + self.isRoot = false + self.value = .init(key: key, element: .object(claims)) + } + + init(root: Bool, claims: [ClaimElement]) { + self.isRoot = root + self.value = .init(key: "", element: .object(claims)) + } +} diff --git a/Sources/JSONWebToken/Claims/StringClaim.swift b/Sources/JSONWebToken/Claims/StringClaim.swift new file mode 100644 index 0000000..b3f78a6 --- /dev/null +++ b/Sources/JSONWebToken/Claims/StringClaim.swift @@ -0,0 +1,30 @@ +/* + * Copyright 2024 Gonçalo Frade + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// Represents a string claim within a JWT. +public struct StringClaim: Claim { + public var value: ClaimElement + + /// Initializes a `StringClaim` with a key and a string value. + /// - Parameters: + /// - key: The key for the claim. + /// - value: The string value for the claim. + public init(key: String, value: String) { + self.value = ClaimElement(key: key, element: .codable(value)) + } +} diff --git a/Sources/JSONWebToken/Claims/SubjectClaim.swift b/Sources/JSONWebToken/Claims/SubjectClaim.swift new file mode 100644 index 0000000..74fd967 --- /dev/null +++ b/Sources/JSONWebToken/Claims/SubjectClaim.swift @@ -0,0 +1,31 @@ +/* + * Copyright 2024 Gonçalo Frade + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// A type alias for `SubjectClaim`. +typealias SubClaim = SubjectClaim + +/// Represents the "sub" (subject) claim in a JWT. +public struct SubjectClaim: JWTRegisteredClaim { + public var value: ClaimElement + + /// Initializes a `SubjectClaim` with a string value. + /// - Parameter value: The subject value for the claim. + public init(value: String) { + self.value = ClaimElement(key: "sub", element: .codable(value)) + } +} diff --git a/Sources/JSONWebToken/DefaultJWTClaims+Codable.swift b/Sources/JSONWebToken/DefaultJWTClaims+Codable.swift index c2c6226..5bce0e6 100644 --- a/Sources/JSONWebToken/DefaultJWTClaims+Codable.swift +++ b/Sources/JSONWebToken/DefaultJWTClaims+Codable.swift @@ -16,7 +16,7 @@ import Foundation -extension DefaultJWTClaimsImpl: Codable { +extension DefaultJWTClaimsImpl { enum CodingKeys: String, CodingKey { case iss = "iss" case sub = "sub" diff --git a/Sources/JSONWebToken/JWT+Encryption.swift b/Sources/JSONWebToken/JWT+Encryption.swift index b6d5ac4..2218047 100644 --- a/Sources/JSONWebToken/JWT+Encryption.swift +++ b/Sources/JSONWebToken/JWT+Encryption.swift @@ -38,7 +38,7 @@ extension JWT { P: JWERegisteredFieldsHeader, U: JWERegisteredFieldsHeader >( - payload: C, + payload: Codable, protectedHeader: P, unprotectedHeader: U? = nil as DefaultJWEHeaderImpl?, senderKey: JWK?, @@ -50,11 +50,43 @@ extension JWT { ) throws -> JWT { var protectedHeader = protectedHeader protectedHeader.type = "JWT" - + let encodedPayload = try JSONEncoder.jwt.encode(payload) return JWT( - payload: payload, + payload: encodedPayload, format: .jwe(try JWE( - payload: JSONEncoder.jwt.encode(payload), + payload: encodedPayload, + protectedHeader: protectedHeader, + unprotectedHeader: unprotectedHeader, + senderKey: senderKey, + recipientKey: recipientKey, + cek: cek, + initializationVector: initializationVector, + additionalAuthenticationData: additionalAuthenticationData + )) + ) + } + + public static func encrypt< + P: JWERegisteredFieldsHeader, + U: JWERegisteredFieldsHeader + >( + @JWTClaimsBuilder payload: () -> Claim, + protectedHeader: P, + unprotectedHeader: U? = nil as DefaultJWEHeaderImpl?, + senderKey: JWK?, + recipientKey: JWK?, + sharedKey: JWK?, + cek: Data? = nil, + initializationVector: Data? = nil, + additionalAuthenticationData: Data? = nil + ) throws -> JWT { + var protectedHeader = protectedHeader + protectedHeader.type = "JWT" + let encodedPayload = try JSONEncoder.jwt.encode(payload().value) + return JWT( + payload: encodedPayload, + format: .jwe(try JWE( + payload: encodedPayload, protectedHeader: protectedHeader, unprotectedHeader: unprotectedHeader, senderKey: senderKey, @@ -143,7 +175,56 @@ extension JWT { NP: JWERegisteredFieldsHeader, NU: JWERegisteredFieldsHeader >( - payload: C, + payload: Codable, + protectedHeader: P, + unprotectedHeader: U? = nil as DefaultJWEHeaderImpl?, + senderKey: JWK? = nil, + recipientKey: JWK? = nil, + sharedKey: JWK? = nil, + cek: Data? = nil, + initializationVector: Data? = nil, + additionalAuthenticationData: Data? = nil, + nestedProtectedHeader: NP, + nestedUnprotectedHeader: NU? = nil as DefaultJWEHeaderImpl?, + nestedSenderKey: JWK? = nil, + nestedRecipientKey: JWK? = nil, + nestedSharedKey: JWK? = nil, + nestedCek: Data? = nil, + nestedInitializationVector: Data? = nil, + nestedAdditionalAuthenticationData: Data? = nil + ) throws -> JWE { + let jwt = try encrypt( + payload: payload, + protectedHeader: nestedProtectedHeader, + unprotectedHeader: nestedUnprotectedHeader, + senderKey: nestedSenderKey, + recipientKey: nestedRecipientKey, + sharedKey: nestedSharedKey, + cek: nestedCek, + initializationVector: nestedInitializationVector, + additionalAuthenticationData: nestedAdditionalAuthenticationData + ) + + return try encryptAsNested( + jwt: jwt, + protectedHeader: protectedHeader, + unprotectedHeader: unprotectedHeader, + senderKey: senderKey, + recipientKey: recipientKey, + sharedKey: sharedKey, + cek: cek, + initializationVector: initializationVector, + additionalAuthenticationData: additionalAuthenticationData + ) + } + + public static func encryptAsNested< + P: JWERegisteredFieldsHeader, + U: JWERegisteredFieldsHeader, + NP: JWERegisteredFieldsHeader, + NU: JWERegisteredFieldsHeader + >( + @JWTClaimsBuilder payload: () -> Claim, protectedHeader: P, unprotectedHeader: U? = nil as DefaultJWEHeaderImpl?, senderKey: JWK? = nil, diff --git a/Sources/JSONWebToken/JWT+Signing.swift b/Sources/JSONWebToken/JWT+Signing.swift index 5170ff8..0f1728a 100644 --- a/Sources/JSONWebToken/JWT+Signing.swift +++ b/Sources/JSONWebToken/JWT+Signing.swift @@ -30,17 +30,35 @@ extension JWT { /// - Returns: A `JWT` instance in JWS format with the signed payload. /// - Throws: An error if the signing process fails. public static func signed( - payload: C, + payload: Codable, protectedHeader: P, key: JWK? ) throws -> JWT { var protectedHeader = protectedHeader protectedHeader.type = "JWT" - + let encodedPayload = try JSONEncoder.jwt.encode(payload) return JWT( - payload: payload, + payload: encodedPayload, format: .jws(try JWS( - payload: JSONEncoder.jwt.encode(payload), + payload: encodedPayload, + protectedHeader: protectedHeader, + key: key + )) + ) + } + + public static func signed( + @JWTClaimsBuilder payload: () -> Claim, + protectedHeader: P, + key: JWK? + ) throws -> JWT { + var protectedHeader = protectedHeader + protectedHeader.type = "JWT" + let encodedPayload = try JSONEncoder.jwt.encode(payload().value) + return JWT( + payload: encodedPayload, + format: .jws(try JWS( + payload: encodedPayload, protectedHeader: protectedHeader, key: key )) @@ -64,7 +82,30 @@ extension JWT { P: JWSRegisteredFieldsHeader, NP: JWSRegisteredFieldsHeader >( - payload: C, + payload: Codable, + protectedHeader: P, + key: JWK?, + nestedProtectedHeader: NP, + nestedKey: JWK? + ) throws -> JWS { + let jwt = try signed( + payload: payload, + protectedHeader: nestedProtectedHeader, + key: nestedKey + ) + + return try signedAsNested( + jwtString: jwt.jwtString, + protectedHeader: protectedHeader, + key: key + ) + } + + public static func signedAsNested< + P: JWSRegisteredFieldsHeader, + NP: JWSRegisteredFieldsHeader + >( + @JWTClaimsBuilder payload: () -> Claim, protectedHeader: P, key: JWK?, nestedProtectedHeader: NP, diff --git a/Sources/JSONWebToken/JWT+Verification.swift b/Sources/JSONWebToken/JWT+Verification.swift index 9b62e1e..8f32aaf 100644 --- a/Sources/JSONWebToken/JWT+Verification.swift +++ b/Sources/JSONWebToken/JWT+Verification.swift @@ -61,7 +61,7 @@ extension JWT { expectedAudience: expectedAudience ) } - let payload = try JSONDecoder.jwt.decode(C.self, from: jws.payload) + let payload = try JSONDecoder.jwt.decode(DefaultJWTClaimsImpl.self, from: jws.payload) guard try jws.verify(key: senderKey) else { throw JWTError.invalidSignature @@ -71,7 +71,7 @@ extension JWT { expectedIssuer: expectedIssuer, expectedAudience: expectedAudience ) - return .init(payload: payload, format: .jws(jws)) + return .init(payload: jws.payload, format: .jws(jws)) case 5: let jwe = try JWE(compactString: jwtString) @@ -95,8 +95,14 @@ extension JWT { expectedAudience: expectedAudience ) } - let payload = try JSONDecoder.jwt.decode(C.self, from: decryptedPayload) - return .init(payload: payload, format: .jwe(jwe)) + let payload = try JSONDecoder.jwt.decode(DefaultJWTClaimsImpl.self, from: decryptedPayload) + try validateClaims( + claims: payload, + expectedIssuer: expectedIssuer, + expectedAudience: expectedAudience + ) + + return .init(payload: decryptedPayload, format: .jwe(jwe)) default: throw JWTError.somethingWentWrong } @@ -113,35 +119,35 @@ public func validateClaims( // Validate Issuer if let expectedIssuer = expectedIssuer, let issuer = claims.iss { guard issuer == expectedIssuer else { - throw DefaultJWT.JWTError.issuerMismatch + throw JWT.JWTError.issuerMismatch } } // Validate Expiration Time if let expirationTime = claims.exp { guard currentDate < expirationTime else { - throw DefaultJWT.JWTError.expired + throw JWT.JWTError.expired } } // Validate Not Before Time if let notBeforeTime = claims.nbf { guard currentDate >= notBeforeTime else { - throw DefaultJWT.JWTError.notYetValid + throw JWT.JWTError.notYetValid } } // Validate Issued At if let issuedAt = claims.iat { guard issuedAt <= currentDate else { - throw DefaultJWT.JWTError.issuedInTheFuture + throw JWT.JWTError.issuedInTheFuture } } // Validate Audience if let expectedAudience = expectedAudience, let audience = claims.aud { guard audience.contains(expectedAudience) else { - throw DefaultJWT.JWTError.audienceMismatch + throw JWT.JWTError.audienceMismatch } } diff --git a/Sources/JSONWebToken/JWT.swift b/Sources/JSONWebToken/JWT.swift index 1ceb386..4f16144 100644 --- a/Sources/JSONWebToken/JWT.swift +++ b/Sources/JSONWebToken/JWT.swift @@ -20,12 +20,7 @@ import JSONWebEncryption import JSONWebKey /// `JWT` represents a JSON Web Token which is a compact, URL-safe means of representing claims to be transferred between two parties. -/// -/// The `JWT` struct is generic over `C`, which must conform to the `JWTRegisteredFieldsClaims` protocol. This allows for flexibility in defining the set of claims a JWT can carry. -/// -/// - Parameters: -/// - C: The type of claims the JWT carries. Must conform to `JWTRegisteredFieldsClaims`. -public struct JWT { +public struct JWT { /// `Format` is an enumeration that defines the two possible formats for a JWT: JWE and JWS. public enum Format { /// JWE format, representing an encrypted JWT. @@ -36,7 +31,7 @@ public struct JWT { } /// The payload of the JWT, containing the claims. - public let payload: C + public let payload: Data /// The format of the JWT, either JWE (encrypted) or JWS (signed). public let format: Format @@ -53,11 +48,28 @@ public struct JWT { } } - public init(payload: C, format: Format) { + /// Initializes a `JWT` with a payload and format. + /// - Parameters: + /// - payload: The payload data. + /// - format: The format of the JWT, either JWE or JWS. + public init(payload: Data, format: Format) { self.payload = payload self.format = format } + /// Initializes a `JWT` with a format and a builder for the payload. + /// - Parameters: + /// - format: The format of the JWT, either JWE or JWS. + /// - payload: A closure that returns a `Claim` using the result builder. + /// - Throws: An error if the encoding process fails. + public init(format: Format, @JWTClaimsBuilder payload: () -> Claim) throws { + self.payload = try JSONEncoder.jwt.encode(payload().value) + self.format = format + } + + /// Initializes a `JWT` from its compact string representation. + /// - Parameter jwtString: The compact string representation of the JWT. + /// - Throws: An error if the decoding process fails. public init(jwtString: String) throws { self.payload = try Self.getPayload(jwtString: jwtString) self.format = try Self.jwtFormat(jwtString: jwtString) @@ -65,10 +77,18 @@ public struct JWT { } public extension JWT { - static func getPayload(jwtString: String) throws -> Payload { + /// Retrieves the payload from a JWT string and decodes it to a specified type. + /// - Parameter jwtString: The compact string representation of the JWT. + /// - Throws: An error if the decoding process fails. + /// - Returns: The decoded payload. + static func getPayload(jwtString: String) throws -> Payload { return try JSONDecoder.jwt.decode(Payload.self, from: getPayload(jwtString: jwtString)) } + /// Retrieves the payload data from a JWT string. + /// - Parameter jwtString: The compact string representation of the JWT. + /// - Throws: An error if the decoding process fails or if the JWT is in JWE format. + /// - Returns: The payload data. static func getPayload(jwtString: String) throws -> Data { switch try jwtFormat(jwtString: jwtString) { case .jwe: @@ -78,41 +98,73 @@ public extension JWT { } } + /// Retrieves the issuer from a JWT string. + /// - Parameter jwtString: The compact string representation of the JWT. + /// - Throws: An error if the decoding process fails. + /// - Returns: The issuer string, if present. static func getIssuer(jwtString: String) throws -> String? { let payload: DefaultJWTClaimsImpl = try getPayload(jwtString: jwtString) return payload.iss } + /// Retrieves the subject from a JWT string. + /// - Parameter jwtString: The compact string representation of the JWT. + /// - Throws: An error if the decoding process fails. + /// - Returns: The subject string, if present. static func getSubject(jwtString: String) throws -> String? { let payload: DefaultJWTClaimsImpl = try getPayload(jwtString: jwtString) return payload.sub } + /// Retrieves the not-before time from a JWT string. + /// - Parameter jwtString: The compact string representation of the JWT. + /// - Throws: An error if the decoding process fails. + /// - Returns: The not-before date, if present. static func getNotBeforeTime(jwtString: String) throws -> Date? { let payload: DefaultJWTClaimsImpl = try getPayload(jwtString: jwtString) return payload.nbf } + /// Retrieves the expiration time from a JWT string. + /// - Parameter jwtString: The compact string representation of the JWT. + /// - Throws: An error if the decoding process fails. + /// - Returns: The expiration date, if present. static func getExpirationTime(jwtString: String) throws -> Date? { let payload: DefaultJWTClaimsImpl = try getPayload(jwtString: jwtString) return payload.exp } + /// Retrieves the issued-at time from a JWT string. + /// - Parameter jwtString: The compact string representation of the JWT. + /// - Throws: An error if the decoding process fails. + /// - Returns: The issued-at date, if present. static func getIssuedAt(jwtString: String) throws -> Date? { let payload: DefaultJWTClaimsImpl = try getPayload(jwtString: jwtString) return payload.iat } + /// Retrieves the JWT ID from a JWT string. + /// - Parameter jwtString: The compact string representation of the JWT. + /// - Throws: An error if the decoding process fails. + /// - Returns: The JWT ID string, if present. static func getID(jwtString: String) throws -> String? { let payload: DefaultJWTClaimsImpl = try getPayload(jwtString: jwtString) return payload.jti } + /// Retrieves the audience from a JWT string. + /// - Parameter jwtString: The compact string representation of the JWT. + /// - Throws: An error if the decoding process fails. + /// - Returns: An array of audience strings, if present. static func getAudience(jwtString: String) throws -> [String]? { let payload: DefaultJWTClaimsImpl = try getPayload(jwtString: jwtString) return payload.aud } + /// Retrieves the header data from a JWT string. + /// - Parameter jwtString: The compact string representation of the JWT. + /// - Throws: An error if the decoding process fails. + /// - Returns: The header data. static func getHeader(jwtString: String) throws -> Data { switch try jwtFormat(jwtString: jwtString) { case .jwe(let jwe): @@ -122,6 +174,10 @@ public extension JWT { } } + /// Determines the format of a JWT string. + /// - Parameter jwtString: The compact string representation of the JWT. + /// - Throws: An error if the format cannot be determined. + /// - Returns: The format of the JWT. static func jwtFormat(jwtString: String) throws -> Format { let components = jwtString.components(separatedBy: ".") switch components.count { diff --git a/Sources/JSONWebToken/JWTRegisteredFieldsClaims.swift b/Sources/JSONWebToken/JWTRegisteredFieldsClaims.swift index 9f76ee0..1ef4651 100644 --- a/Sources/JSONWebToken/JWTRegisteredFieldsClaims.swift +++ b/Sources/JSONWebToken/JWTRegisteredFieldsClaims.swift @@ -16,11 +16,9 @@ import Foundation -typealias DefaultJWT = JWT - /// `JWTRegisteredFieldsClaims` is a protocol defining the standard claims typically included in a JWT. /// Conforming types can represent the payload of a JWT, encompassing both registered claim names and custom claims. -public protocol JWTRegisteredFieldsClaims: Codable { +public protocol JWTRegisteredFieldsClaims { // "iss" claim representing the issuer of the JWT. var iss: String? { get } // "sub" claim representing the subject of the JWT. @@ -42,7 +40,7 @@ public protocol JWTRegisteredFieldsClaims: Codable { } /// `DefaultJWTClaimsImpl` is a struct implementing the `JWTRegisteredFieldsClaims` protocol, providing a default set of claims. -public struct DefaultJWTClaimsImpl: JWTRegisteredFieldsClaims { +public struct DefaultJWTClaimsImpl: JWTRegisteredFieldsClaims, Codable { public let iss: String? public let sub: String? public let aud: [String]? diff --git a/Tests/JWTTests/JWTTests.swift b/Tests/JWTTests/JWTTests.swift index 462c541..bd364d4 100644 --- a/Tests/JWTTests/JWTTests.swift +++ b/Tests/JWTTests/JWTTests.swift @@ -10,7 +10,7 @@ final class JWTTests: XCTestCase { eyJhbGciOiJub25lIn0.eyJpc3MiOiJ0ZXN0QWxpY2UiLCJzdWIiOiJBbGljZSIsInRlc3RDbGFpbSI6InRlc3RlZENsYWltIn0. """ - let jwt = try JWT.verify(jwtString: jwtString) + let jwt = try JWT.verify(jwtString: jwtString) switch jwt.format { case .jws(let jws): XCTAssertEqual(jws.protectedHeader.algorithm!, .none) @@ -20,7 +20,7 @@ final class JWTTests: XCTestCase { XCTFail("Wrong JWT format") } - XCTAssertEqual(jwt.payload.iss, "testAlice") + XCTAssertEqual(try JSONDecoder.jwt.decode(DefaultJWTClaimsImpl.self, from: jwt.payload).iss, "testAlice") } func testSignAndVerify() throws { @@ -45,8 +45,8 @@ final class JWTTests: XCTestCase { XCTAssertTrue(jwtString.contains("eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9")) XCTAssertTrue(jwtString.contains("eyJpYXQiOjIwMCwiaXNzIjoidGVzdEFsaWNlIiwic3ViIjoiQWxpY2UiLCJ0ZXN0Q2xhaW0iOiJ0ZXN0ZWRDbGFpbSJ9")) - let verifiedJWT = try JWT.verify(jwtString: jwtString, senderKey: key) - let verifiedPayload = verifiedJWT.payload + let verifiedJWT = try JWT.verify(jwtString: jwtString, senderKey: key) + let verifiedPayload = try JSONDecoder.jwt.decode(MockExampleClaims.self, from: verifiedJWT.payload) XCTAssertEqual(verifiedPayload.iss, "testAlice") XCTAssertEqual(verifiedPayload.sub, "Alice") XCTAssertEqual(verifiedPayload.iat, issuedAt) @@ -77,7 +77,7 @@ final class JWTTests: XCTestCase { let jwtString = jwt.jwtString - XCTAssertThrowsError(try JWT.verify(jwtString: jwtString, senderKey: key)) + XCTAssertThrowsError(try JWT.verify(jwtString: jwtString, senderKey: key)) } func testFailNotBeforeValidation() throws { @@ -98,7 +98,7 @@ final class JWTTests: XCTestCase { let jwtString = jwt.jwtString - XCTAssertThrowsError(try JWT.verify(jwtString: jwtString, senderKey: key)) + XCTAssertThrowsError(try JWT.verify(jwtString: jwtString, senderKey: key)) } func testFailIssuedAtValidation() throws { @@ -119,7 +119,7 @@ final class JWTTests: XCTestCase { let jwtString = jwt.jwtString - XCTAssertThrowsError(try JWT.verify(jwtString: jwtString, senderKey: key)) + XCTAssertThrowsError(try JWT.verify(jwtString: jwtString, senderKey: key)) } func testFailIssuerValidation() throws { @@ -140,7 +140,7 @@ final class JWTTests: XCTestCase { let jwtString = jwt.jwtString - XCTAssertThrowsError(try JWT.verify( + XCTAssertThrowsError(try JWT.verify( jwtString: jwtString, senderKey: key, expectedIssuer: "Bob" @@ -164,10 +164,143 @@ final class JWTTests: XCTestCase { let jwtString = jwt.jwtString - XCTAssertThrowsError(try JWT.verify( + XCTAssertThrowsError(try JWT.verify( jwtString: jwtString, senderKey: key, expectedAudience: "Bob" )) } + + func testClaims() throws { + let result = JWTClaimsBuilder.build { + IssuerClaim(value: "testIssuer") + SubjectClaim(value: "testSubject") + ExpirationTimeClaim(value: Date(timeIntervalSince1970: 1609459200)) // Fixed date for testing + IssuedAtClaim(value: Date(timeIntervalSince1970: 1609459200)) + NotBeforeClaim(value: Date(timeIntervalSince1970: 1609459200)) + JWTIdentifierClaim(value: "ThisIdentifier") + AudienceClaim(value: "testAud") + StringClaim(key: "testStr1", value: "value1") + NumberClaim(key: "testN1", value: 0) + NumberClaim(key: "testN2", value: 1.1) + NumberClaim(key: "testN3", value: Double(1.233232)) + BoolClaim(key: "testBool1", value: true) + ArrayClaim(key: "testArray") { + ArrayElementClaim.string("valueArray1") + ArrayElementClaim.string("valueArray2") + ArrayElementClaim.bool(true) + ArrayElementClaim.array { + ArrayElementClaim.string("nestedNestedArray1") + } + ArrayElementClaim.object { + StringClaim(key: "nestedNestedObject", value: "nestedNestedValue") + } + } + ObjectClaim(key: "testObject") { + StringClaim(key: "testDicStr1", value: "valueDic1") + } + } + + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + encoder.dateEncodingStrategy = .secondsSince1970 + let coded = try encoder.encode(result.value) + + let jsonString = try XCTUnwrap(String(data: coded, encoding: .utf8)) + print(jsonString) + + // Verify the structure of the resulting JSON + let expectedJSON = """ + { + "aud":"testAud", + "exp":1609459200, + "iat":1609459200, + "iss":"testIssuer", + "jti":"ThisIdentifier", + "nbf":1609459200, + "sub":"testSubject", + "testArray":[ + "valueArray1", + "valueArray2", + true, + ["nestedNestedArray1"], + {"nestedNestedObject":"nestedNestedValue"} + ], + "testBool1":true, + "testN1":0, + "testN2":1.1, + "testN3":1.233232, + "testObject":{"testDicStr1":"valueDic1"}, + "testStr1":"value1" + } + """ + + XCTAssertTrue(areJSONStringsEqual(jsonString, expectedJSON)) + } + + func testEmptyClaims() throws { + let result = JWTClaimsBuilder.build { + } + + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + encoder.dateEncodingStrategy = .secondsSince1970 + let coded = try encoder.encode(result.value) + + let jsonString = try XCTUnwrap(String(data: coded, encoding: .utf8)) + print(jsonString) + + // Verify the structure of the resulting JSON + let expectedJSON = "{}" + + XCTAssertTrue(areJSONStringsEqual(jsonString, expectedJSON)) + } + + func testSingleClaim() throws { + let result = JWTClaimsBuilder.build { + IssuerClaim(value: "singleIssuer") + } + + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + encoder.dateEncodingStrategy = .secondsSince1970 + let coded = try encoder.encode(result.value) + + let jsonString = try XCTUnwrap(String(data: coded, encoding: .utf8)) + print(jsonString) + + // Verify the structure of the resulting JSON + let expectedJSON = """ + { + "iss":"singleIssuer" + } + """ + + XCTAssertTrue(areJSONStringsEqual(jsonString, expectedJSON)) + } + + private func areJSONStringsEqual(_ lhs: String, _ rhs: String) -> Bool { + guard + let lhsData = lhs.data(using: .utf8), + let rhsData = rhs.data(using: .utf8), + let lhsObject = try? JSONSerialization.jsonObject(with: lhsData, options: []), + let rhsObject = try? JSONSerialization.jsonObject(with: rhsData, options: []) + else { + return false + } + return NSDictionary(dictionary: lhsObject as? [String: Any] ?? [:]) + .isEqual(to: rhsObject as? [String: Any] ?? [:]) + } +} + +extension String { + /// Returns a new string with all whitespace and newline characters removed. + /// + /// This method creates a new string with all occurrences of whitespace and newline characters (spaces and line breaks) removed. The original string is not modified. + /// + /// - Returns: A new string with all whitespace and newline characters removed. + func replacingWhiteSpacesAndNewLines() -> String { + replacingOccurrences(of: " ", with: "") + .replacingOccurrences(of: "\n", with: "") + } } diff --git a/Tests/JWTTests/Mock/MockExampleClaims.swift b/Tests/JWTTests/Mock/MockExampleClaims.swift index 4e704db..9b784e7 100644 --- a/Tests/JWTTests/Mock/MockExampleClaims.swift +++ b/Tests/JWTTests/Mock/MockExampleClaims.swift @@ -1,7 +1,7 @@ import Foundation import JSONWebToken -struct MockExampleClaims: JWTRegisteredFieldsClaims { +struct MockExampleClaims: JWTRegisteredFieldsClaims, Codable { let iss: String? let sub: String? let aud: [String]?