├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── .spi.yml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── Jot │ ├── Coding.swift │ ├── Data+Base64URLEncode.swift │ ├── JSONWebKey.swift │ ├── JSONWebToken.swift │ └── Jot+CryptoKit.swift └── Tests └── JotTests ├── JSONEncodingTests.swift ├── JSONWebKeyTests.swift └── JSONWebTokenTests.swift /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [mattmassicotte] 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - 'README.md' 9 | - 'CODE_OF_CONDUCT.md' 10 | - '.editorconfig' 11 | - '.spi.yml' 12 | pull_request: 13 | branches: 14 | - main 15 | 16 | concurrency: 17 | group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} 18 | cancel-in-progress: true 19 | 20 | jobs: 21 | test: 22 | name: Test 23 | runs-on: macOS-15 24 | timeout-minutes: 30 25 | env: 26 | DEVELOPER_DIR: /Applications/Xcode_16.2.app 27 | strategy: 28 | matrix: 29 | destination: 30 | - "platform=macOS" 31 | - "platform=macOS,variant=Mac Catalyst" 32 | - "platform=iOS Simulator,name=iPhone 16" 33 | - "platform=tvOS Simulator,name=Apple TV" 34 | - "platform=watchOS Simulator,name=Apple Watch Series 10 (42mm)" 35 | - "platform=visionOS Simulator,name=Apple Vision Pro" 36 | steps: 37 | - uses: actions/checkout@v4 38 | - name: Test platform ${{ matrix.destination }} 39 | run: set -o pipefail && xcodebuild -scheme Jot -destination "${{ matrix.destination }}" test | xcbeautify 40 | 41 | linux_test: 42 | name: Test Linux 43 | runs-on: ubuntu-latest 44 | timeout-minutes: 30 45 | steps: 46 | - name: Checkout 47 | uses: actions/checkout@v4 48 | - name: Swiftly 49 | uses: vapor/swiftly-action@v0.2.0 50 | with: 51 | toolchain: 6.0.3 52 | - name: Test 53 | run: swift test 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [Jot] 5 | 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual 11 | identity and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the overall 27 | community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or advances of 32 | any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email address, 36 | without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at https://mastodon.social/@mattiem. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2025, Matt Massicotte 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Jot", 7 | platforms: [ 8 | .macOS(.v10_15), 9 | .macCatalyst(.v13), 10 | .iOS(.v13), 11 | .tvOS(.v13), 12 | .watchOS(.v7), 13 | .visionOS(.v1), 14 | ], 15 | products: [ 16 | .library(name: "Jot", targets: ["Jot"]), 17 | ], 18 | targets: [ 19 | .target(name: "Jot"), 20 | .testTarget( 21 | name: "JotTests", 22 | dependencies: ["Jot"] 23 | ), 24 | ] 25 | ) 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | [![Build Status][build status badge]][build status] 4 | [![Platforms][platforms badge]][platforms] 5 | [![Documentation][documentation badge]][documentation] 6 | [![Matrix][matrix badge]][matrix] 7 | 8 |
9 | 10 | # Jot 11 | Very simple JWT/JWK library for Swift 12 | 13 | There are a lot of really good libraries out there for handling this stuff. However, many of them have the goal of supporting all possible cryptographic algorithms and non-Apple platforms. I just wanted something simple that worked with [CryptoKit](https://developer.apple.com/documentation/cryptokit/), so that's what this is. 14 | 15 | However, it does abstract the algorithms, so it is possible to use this library with other cryptography systems if you'd like. 16 | 17 | This all came from work supporting DPoP as part of OAuth 2.1 in [OAuthenticator](https://github.com/ChimeHQ/OAuthenticator). 18 | 19 | ## Integration 20 | 21 | ```swift 22 | dependencies: [ 23 | .package(url: "https://github.com/mattmassicotte/Jot", branch: "main") 24 | ] 25 | ``` 26 | 27 | ## Usage 28 | 29 | ```swift 30 | import Jot 31 | 32 | // Define your custom payload. You can omit fields that you do not need. 33 | struct MyCustomPayload : JSONWebTokenPayload { 34 | let iss: String? 35 | let sub: String? 36 | let aud: JSONWebTokenAudience? 37 | let jti: String? 38 | let nbf: Date? 39 | let iat: Date? 40 | let exp: Date? 41 | 42 | let customClaim: String 43 | } 44 | 45 | // create a token 46 | let token = JSONWebToken( 47 | header: JSONWebTokenHeader(algorithm: .ES256), 48 | payload: MyCustomPayload(iss: nil, sub: nil, aud: nil, jti: nil, nbf: nil, iat: nil, exp: nil, customClaim: "my_claim") 49 | ) 50 | 51 | import CryptoKit 52 | 53 | let key = P256.Signing.PrivateKey() 54 | 55 | // encode it 56 | let string = token.encode(with: key) 57 | 58 | // decode it 59 | let decodedToken = JSONWebToken(encodedString: string, key: key) 60 | ``` 61 | 62 | Jot also supports custom signing/verification if CryptoKit is unavailable, or you want to use an algorithm that is does not support. 63 | 64 | ```swift 65 | // custom signature 66 | let string = token.encode { algorithm, data in 67 | // custom JSONWebTokenSigner implementation goes here 68 | 69 | return signature 70 | } 71 | 72 | // custom verification 73 | let token = try JSONWebToken(encodedString: tokenString) { algorithm, message, signature in 74 | // custom JSONWebTokenValidator implementation goes here 75 | } 76 | ``` 77 | 78 | ## Supported Algorithms 79 | 80 | Remember, this library supports bring-your-own-cyptography-system. But, when CryptoKit is available, there are some convenience implementations for `ES256`, `HS256`, `HS384`, `HS512`. If you need something else open up an issue and we can get it going. 81 | 82 | ## Contributing and Collaboration 83 | 84 | I would love to hear from you! Issues or pull requests work great. Both a [Matrix space][matrix] and [Discord][discord] are available for live help, but I have a strong bias towards answering in the form of documentation. You can also find me on [the web](https://www.massicotte.org). 85 | 86 | I prefer collaboration, and would love to find ways to work together if you have a similar project. 87 | 88 | I prefer indentation with tabs for improved accessibility. But, I'd rather you use the system you want and make a PR than hesitate because of whitespace. 89 | 90 | By participating in this project you agree to abide by the [Contributor Code of Conduct](CODE_OF_CONDUCT.md). 91 | 92 | [build status]: https://github.com/mattmassicotte/Jot/actions 93 | [build status badge]: https://github.com/mattmassicotte/Jot/workflows/CI/badge.svg 94 | [platforms]: https://swiftpackageindex.com/mattmassicotte/Jot 95 | [platforms badge]: https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fmattmassicotte%2FJot%2Fbadge%3Ftype%3Dplatforms 96 | [documentation]: https://swiftpackageindex.com/mattmassicotte/Jot/main/documentation 97 | [documentation badge]: https://img.shields.io/badge/Documentation-DocC-blue 98 | [matrix]: https://matrix.to/#/%23chimehq%3Amatrix.org 99 | [matrix badge]: https://img.shields.io/matrix/chimehq%3Amatrix.org?label=Matrix 100 | [discord]: https://discord.gg/esFpX6sErJ 101 | -------------------------------------------------------------------------------- /Sources/Jot/Coding.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension JSONEncoder { 4 | public static var jsonWebTokenEncoder: JSONEncoder { 5 | let encoder = JSONEncoder() 6 | 7 | encoder.dataEncodingStrategy = .custom({ data, encoder in 8 | var container = encoder.singleValueContainer() 9 | 10 | try container.encode(data.base64EncodedURLEncodedString()) 11 | }) 12 | 13 | encoder.dateEncodingStrategy = .custom({ date, encoder in 14 | var container = encoder.singleValueContainer() 15 | 16 | try container.encode(Int(date.timeIntervalSince1970)) 17 | }) 18 | 19 | encoder.outputFormatting = .sortedKeys 20 | 21 | return encoder 22 | } 23 | } 24 | 25 | extension JSONDecoder { 26 | public static var jsonWebTokenDecoder: JSONDecoder { 27 | let decoder = JSONDecoder() 28 | 29 | decoder.dataDecodingStrategy = .custom({ decoder in 30 | let container = try decoder.singleValueContainer() 31 | 32 | let base64 = try container.decode(String.self) 33 | 34 | guard let data = Data(base64URLEncoded: base64) else { 35 | throw JSONWebTokenError.base64DecodingFailed 36 | } 37 | 38 | return data 39 | }) 40 | 41 | decoder.dateDecodingStrategy = .custom({ decoder in 42 | let container = try decoder.singleValueContainer() 43 | 44 | let value = try container.decode(Int.self) 45 | 46 | return Date(timeIntervalSince1970: TimeInterval(value)) 47 | }) 48 | 49 | return decoder 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/Jot/Data+Base64URLEncode.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Data { 4 | func base64EncodedURLEncodedString() -> String { 5 | base64EncodedString() 6 | .replacingOccurrences(of: "+", with: "-") 7 | .replacingOccurrences(of: "/", with: "_") 8 | .replacingOccurrences(of: "=", with: "") 9 | } 10 | 11 | init?(base64URLEncoded string: String) { 12 | let remainder = string.utf8.count % 4 13 | let paddingCount = remainder == 0 ? 0 : 4 - remainder 14 | 15 | let input = string 16 | .replacingOccurrences(of: "-", with: "+") 17 | .replacingOccurrences(of: "_", with: "/") 18 | .appending(String(repeating: "=", count: paddingCount)) 19 | 20 | print(string, "=>", input) 21 | 22 | self.init(base64Encoded: input) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/Jot/JSONWebKey.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Model of a JSON Web Key. 4 | /// 5 | /// Defined by: https://datatracker.ietf.org/doc/html/rfc7517 6 | public struct JSONWebKey: Hashable, Sendable { 7 | public enum EllipticCurve : String, Hashable, Codable, Sendable { 8 | case P256 = "P-256" 9 | } 10 | 11 | public struct EllipticCurveParameters : Hashable, Sendable { 12 | public let curve: EllipticCurve 13 | public let x: Data 14 | public let y: Data 15 | 16 | public init(curve: EllipticCurve, x: Data, y: Data) { 17 | self.curve = curve 18 | self.x = x 19 | self.y = y 20 | } 21 | } 22 | 23 | public enum KeyType : Hashable, Sendable { 24 | case rsa 25 | case ec(EllipticCurveParameters) 26 | } 27 | 28 | public enum KeyUse : RawRepresentable, Hashable, Sendable { 29 | case signature 30 | case encryption 31 | case custom(String) 32 | 33 | public init?(rawValue: String) { 34 | switch rawValue { 35 | case "sig": 36 | self = .signature 37 | case "enc": 38 | self = .encryption 39 | default: 40 | self = .custom(rawValue) 41 | } 42 | } 43 | 44 | public var rawValue: String { 45 | switch self { 46 | case .encryption: 47 | "enc" 48 | case .signature: 49 | "sig" 50 | case let .custom(value): 51 | value 52 | } 53 | } 54 | } 55 | 56 | public let keyType: KeyType 57 | public let use: KeyUse? 58 | public let id: String? 59 | 60 | public init(keyType: KeyType, use: KeyUse? = nil, id: String? = nil) { 61 | self.keyType = keyType 62 | self.use = use 63 | self.id = id 64 | } 65 | 66 | public init(params: EllipticCurveParameters, use: KeyUse? = nil, id: String? = nil) { 67 | self.init(keyType: .ec(params), use: use, id: id) 68 | } 69 | } 70 | 71 | extension JSONWebKey : Codable { 72 | enum CodingKeys: String, CodingKey { 73 | case keyType = "kty" 74 | case use 75 | case id = "kid" 76 | case curve = "crv" 77 | case ecX = "x" 78 | case ecY = "y" 79 | } 80 | 81 | public init(from decoder: any Decoder) throws { 82 | let container = try decoder.container(keyedBy: CodingKeys.self) 83 | 84 | if let string = try container.decodeIfPresent(String.self, forKey: .use) { 85 | self.use = KeyUse(rawValue: string) 86 | } else { 87 | self.use = nil 88 | } 89 | 90 | self.id = (try container.decodeIfPresent(String.self, forKey: .id)) ?? nil 91 | 92 | let keyType = try container.decode(String.self, forKey: .keyType) 93 | 94 | switch keyType { 95 | case "RSA", "rsa": 96 | self.keyType = .rsa 97 | case "EC", "ec": 98 | let curve = try container.decode(EllipticCurve.self, forKey: .curve) 99 | let ecX = try container.decode(Data.self, forKey: .ecX) 100 | let ecY = try container.decode(Data.self, forKey: .ecY) 101 | 102 | self.keyType = .ec(EllipticCurveParameters(curve: curve, x: ecX, y: ecY)) 103 | default: 104 | throw DecodingError.typeMismatch( 105 | JSONWebKey.self, 106 | DecodingError.Context.init( 107 | codingPath: container.codingPath, 108 | debugDescription: "Key type not decodable", 109 | underlyingError: nil 110 | ) 111 | ) 112 | } 113 | } 114 | 115 | public func encode(to encoder: any Encoder) throws { 116 | var container = encoder.container(keyedBy: CodingKeys.self) 117 | 118 | switch keyType { 119 | case .rsa: 120 | try container.encode("RSA", forKey: .keyType) 121 | case let .ec(params): 122 | try container.encode("EC", forKey: .keyType) 123 | 124 | try container.encode(params.curve, forKey: .curve) 125 | try container.encode(params.x, forKey: .ecX) 126 | try container.encode(params.y, forKey: .ecY) 127 | } 128 | 129 | if let use { 130 | try container.encode(use.rawValue, forKey: .use) 131 | } 132 | 133 | if let id { 134 | try container.encode(id, forKey: .id) 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /Sources/Jot/JSONWebToken.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum JSONWebTokenError : Error { 4 | case signatureInvalid 5 | case structureInvalid 6 | case base64DecodingFailed 7 | case algorithmMismatch(JSONWebTokenAlgorithm, JSONWebTokenAlgorithm) 8 | case algorithmUnsupported(JSONWebTokenAlgorithm) 9 | } 10 | 11 | public enum JSONWebTokenAlgorithm : String, Codable, Hashable, Sendable { 12 | case HS256 13 | case HS384 14 | case HS512 15 | case RS256 16 | case RS384 17 | case RS512 18 | 19 | case ES256 20 | case ES384 21 | case ES512 22 | 23 | case PS256 24 | case PS384 25 | case PS512 26 | 27 | case none 28 | 29 | public func check(_ other: JSONWebTokenAlgorithm) throws { 30 | if self != other { 31 | throw JSONWebTokenError.algorithmMismatch(self, other) 32 | } 33 | } 34 | } 35 | 36 | public struct JSONWebTokenHeader : Codable, Hashable, Sendable { 37 | public var algorithm: JSONWebTokenAlgorithm 38 | public var type: String? 39 | public var keyId: String? 40 | public var jwk: JSONWebKey? 41 | 42 | enum CodingKeys: String, CodingKey { 43 | case algorithm = "alg" 44 | case type = "typ" 45 | case keyId = "kid" 46 | case jwk = "jwk" 47 | } 48 | 49 | public init(algorithm: JSONWebTokenAlgorithm, type: String? = nil, keyId: String? = nil, jwk: JSONWebKey? = nil) { 50 | self.algorithm = algorithm 51 | self.type = type 52 | self.keyId = keyId 53 | self.jwk = jwk 54 | } 55 | } 56 | 57 | public enum JSONWebTokenAudience : Hashable, Sendable { 58 | case single(String) 59 | case array([String]) 60 | } 61 | 62 | extension JSONWebTokenAudience : Codable { 63 | public init(from decoder: any Decoder) throws { 64 | let container = try decoder.singleValueContainer() 65 | 66 | if let string = try? container.decode(String.self) { 67 | self = .single(string) 68 | return 69 | } 70 | 71 | if let array = try? container.decode([String].self) { 72 | self = .array(array) 73 | return 74 | } 75 | 76 | throw DecodingError.typeMismatch( 77 | JSONWebTokenAudience.self, 78 | DecodingError.Context.init( 79 | codingPath: container.codingPath, 80 | debugDescription: "Audience value could not be decoded", 81 | underlyingError: nil 82 | ) 83 | ) 84 | } 85 | 86 | public func encode(to encoder: any Encoder) throws { 87 | switch self { 88 | case let .single(string): 89 | try string.encode(to: encoder) 90 | case let .array(array): 91 | try array.encode(to: encoder) 92 | } 93 | } 94 | } 95 | 96 | public protocol JSONWebTokenPayload : Codable { 97 | /// Issuer 98 | var iss: String? { get } 99 | /// Subject 100 | var sub: String? { get } 101 | /// Audience 102 | var aud: JSONWebTokenAudience? { get } 103 | /// Unique Code 104 | var jti: String? { get } 105 | /// Not Before 106 | var nbf: Date? { get } 107 | /// Created At 108 | var iat: Date? { get } 109 | /// Expires At 110 | var exp: Date? { get } 111 | } 112 | 113 | extension JSONWebTokenPayload { 114 | public var iss: String? { nil } 115 | public var sub: String? { nil } 116 | public var aud: JSONWebTokenAudience? { nil } 117 | public var jti: String? { nil } 118 | public var nbf: Date? { nil } 119 | public var iat: Date? { nil } 120 | public var exp: Date? { nil } 121 | } 122 | 123 | public typealias JSONWebTokenSigner = (JSONWebTokenAlgorithm, Data) throws -> Data 124 | public typealias JSONWebTokenValidator = (JSONWebTokenAlgorithm, _ message: Data, _ signature: Data) throws -> Bool 125 | 126 | public struct JSONWebToken { 127 | public let header: JSONWebTokenHeader 128 | public let payload: Payload 129 | 130 | public init(header: JSONWebTokenHeader, payload: Payload) { 131 | self.header = header 132 | self.payload = payload 133 | } 134 | 135 | public func encode(with signer: JSONWebTokenSigner) throws -> String { 136 | let encoder = JSONEncoder.jsonWebTokenEncoder 137 | 138 | let headerString = try encoder.encode(header).base64EncodedURLEncodedString() 139 | let payloadString = try encoder.encode(payload).base64EncodedURLEncodedString() 140 | 141 | let inputData = [headerString, payloadString].joined(separator: ".") 142 | let signatureData = try signer(header.algorithm, Data(inputData.utf8)) 143 | 144 | let signature = signatureData.base64EncodedURLEncodedString() 145 | 146 | return [headerString, payloadString, signature].joined(separator: ".") 147 | } 148 | } 149 | 150 | extension JSONWebToken : Equatable where Payload : Equatable {} 151 | extension JSONWebToken : Hashable where Payload : Hashable {} 152 | extension JSONWebToken : Sendable where Payload : Sendable {} 153 | 154 | extension JSONWebToken { 155 | public init(encodedString: String, validator: JSONWebTokenValidator) throws { 156 | let components = encodedString.components(separatedBy: ".") 157 | guard components.count == 3 else { 158 | throw JSONWebTokenError.structureInvalid 159 | } 160 | 161 | guard 162 | let headerData = Data(base64URLEncoded: components[0]), 163 | let payloadData = Data(base64URLEncoded: components[1]), 164 | let signatureData = Data(base64URLEncoded: components[2]) 165 | else { 166 | throw JSONWebTokenError.base64DecodingFailed 167 | } 168 | 169 | let decoder = JSONDecoder.jsonWebTokenDecoder 170 | 171 | self.header = try decoder.decode(JSONWebTokenHeader.self, from: headerData) 172 | self.payload = try decoder.decode(Payload.self, from: payloadData) 173 | 174 | let message = Data(components.dropLast().joined(separator: ".").utf8) 175 | 176 | guard try validator(self.header.algorithm, message, signatureData) else { 177 | throw JSONWebTokenError.signatureInvalid 178 | } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /Sources/Jot/Jot+CryptoKit.swift: -------------------------------------------------------------------------------- 1 | #if canImport(CryptoKit) 2 | import CryptoKit 3 | import Foundation 4 | 5 | extension Data { 6 | var ellipticCurveComponents: (Data, Data) { 7 | let size = count / 2 8 | 9 | return (prefix(size), suffix(size)) 10 | } 11 | } 12 | 13 | extension JSONWebKey.EllipticCurveParameters { 14 | public init(p256Key: P256.Signing.PublicKey) { 15 | let (x, y) = p256Key.rawRepresentation.ellipticCurveComponents 16 | 17 | self.init(curve: .P256, x: x, y: y) 18 | } 19 | } 20 | 21 | extension JSONWebKey { 22 | public init(p256Key: P256.Signing.PublicKey, use: KeyUse? = nil, id: String? = nil) { 23 | let curve = EllipticCurveParameters(p256Key: p256Key) 24 | 25 | self.init(params: curve, use: use, id: id) 26 | } 27 | } 28 | 29 | extension JSONWebToken { 30 | public func encode(with privateKey: P256.Signing.PrivateKey) throws -> String { 31 | try encode { algo, data in 32 | try algo.check(.ES256) 33 | 34 | let digest = SHA256.hash(data: data) 35 | 36 | let sig = try privateKey.signature(for: digest) 37 | 38 | return sig.rawRepresentation 39 | } 40 | } 41 | 42 | public init(encodedString: String, key: P256.Signing.PublicKey) throws { 43 | try self.init(encodedString: encodedString) { algo, message, signature in 44 | try algo.check(.ES256) 45 | 46 | let sig = try P256.Signing.ECDSASignature(rawRepresentation: signature) 47 | 48 | return key.isValidSignature(sig, for: message) 49 | } 50 | } 51 | } 52 | 53 | extension JSONWebToken { 54 | public func encode(with key: SymmetricKey) throws -> String { 55 | try encode { algo, data in 56 | switch algo { 57 | case .HS256: 58 | let sig = HMAC.authenticationCode(for: data, using: key) 59 | 60 | return Data(sig) 61 | case .HS384: 62 | let sig = HMAC.authenticationCode(for: data, using: key) 63 | 64 | return Data(sig) 65 | case .HS512: 66 | let sig = HMAC.authenticationCode(for: data, using: key) 67 | 68 | return Data(sig) 69 | default: 70 | throw JSONWebTokenError.algorithmUnsupported(algo) 71 | } 72 | } 73 | } 74 | 75 | public init(encodedString: String, key: SymmetricKey) throws { 76 | try self.init(encodedString: encodedString) { algo, message, signature in 77 | switch algo { 78 | case .HS256: 79 | let sig = HMAC.authenticationCode(for: message, using: key) 80 | 81 | return sig == signature 82 | case .HS384: 83 | let sig = HMAC.authenticationCode(for: message, using: key) 84 | 85 | return sig == signature 86 | case .HS512: 87 | let sig = HMAC.authenticationCode(for: message, using: key) 88 | 89 | return sig == signature 90 | default: 91 | throw JSONWebTokenError.algorithmUnsupported(algo) 92 | } 93 | } 94 | } 95 | } 96 | #endif 97 | -------------------------------------------------------------------------------- /Tests/JotTests/JSONEncodingTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | 4 | import Jot 5 | 6 | struct JSONEncodingTests { 7 | @Test func encodeData() throws { 8 | let value = try JSONEncoder.jsonWebTokenEncoder.encode(Data("foo+bar/baz".utf8)) 9 | 10 | #expect(String(decoding: value, as: UTF8.self) == "\"Zm9vK2Jhci9iYXo\"") 11 | } 12 | 13 | @Test func decodeData() throws { 14 | let input = "\"Zm9vK2Jhci9iYXo\"" 15 | let value = try JSONDecoder.jsonWebTokenDecoder.decode(Data.self, from: Data(input.utf8)) 16 | 17 | #expect(String(decoding: value, as: UTF8.self) == "foo+bar/baz") 18 | } 19 | 20 | @Test func noPadding() throws { 21 | let input = "\"YWJj\"" 22 | let value = try JSONDecoder.jsonWebTokenDecoder.decode(Data.self, from: Data(input.utf8)) 23 | 24 | #expect(String(decoding: value, as: UTF8.self) == "abc") 25 | } 26 | 27 | @Test func onePadding() throws { 28 | let input = "\"YWI\"" 29 | let value = try JSONDecoder.jsonWebTokenDecoder.decode(Data.self, from: Data(input.utf8)) 30 | 31 | #expect(String(decoding: value, as: UTF8.self) == "ab") 32 | } 33 | 34 | @Test func twoPadding() throws { 35 | let input = "\"YQ\"" 36 | let value = try JSONDecoder.jsonWebTokenDecoder.decode(Data.self, from: Data(input.utf8)) 37 | 38 | #expect(String(decoding: value, as: UTF8.self) == "a") 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Tests/JotTests/JSONWebKeyTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | 4 | import Jot 5 | 6 | struct JSONWebKeyTests { 7 | @Test func ecKeyCoding() throws { 8 | let key = JSONWebKey( 9 | params: .init(curve: .P256, x: Data("abc".utf8), y: Data("def".utf8)), 10 | use: .signature, 11 | id: "keyid" 12 | ) 13 | 14 | let output = try JSONEncoder.jsonWebTokenEncoder.encode(key) 15 | 16 | let decoded = try JSONDecoder.jsonWebTokenDecoder.decode(JSONWebKey.self, from: output) 17 | 18 | #expect(key == decoded) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/JotTests/JSONWebTokenTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | 4 | import Jot 5 | 6 | struct MockPayload : JSONWebTokenPayload, Equatable { 7 | let iss: String? 8 | let sub: String? 9 | let aud: JSONWebTokenAudience? 10 | let jti: String? 11 | let nbf: Date? 12 | let iat: Date? 13 | let exp: Date? 14 | 15 | let customClaim: String 16 | } 17 | 18 | typealias MockToken = JSONWebToken 19 | 20 | struct JSONWebTokenTests { 21 | @Test func headerCoding() throws { 22 | let header = JSONWebTokenHeader(algorithm: .ES256) 23 | 24 | let data = try JSONEncoder.jsonWebTokenEncoder.encode(header) 25 | let decoded = try JSONDecoder.jsonWebTokenDecoder.decode(JSONWebTokenHeader.self, from: data) 26 | 27 | #expect(header == decoded) 28 | } 29 | 30 | @Test func singleAudienceDecoding() throws { 31 | let singleString = "\"single\"" 32 | let single = try JSONDecoder().decode(JSONWebTokenAudience.self, from: Data(singleString.utf8)) 33 | 34 | #expect(single == .single("single")) 35 | } 36 | 37 | @Test func singleAudienceEncoding() throws { 38 | let single = try JSONEncoder().encode(JSONWebTokenAudience.single("single")) 39 | 40 | #expect(String(decoding: single, as: UTF8.self) == "\"single\"") 41 | } 42 | 43 | @Test func arrayAudienceDecoding() throws { 44 | let singleString = "[\"one\",\"two\"]" 45 | let audience = try JSONDecoder().decode(JSONWebTokenAudience.self, from: Data(singleString.utf8)) 46 | 47 | #expect(audience == .array(["one", "two"])) 48 | } 49 | 50 | @Test func arrayAudienceEncoding() throws { 51 | let data = try JSONEncoder().encode(JSONWebTokenAudience.array(["one", "two"])) 52 | let array = try JSONDecoder().decode([String].self, from: data) 53 | 54 | #expect(array == ["one", "two"]) 55 | } 56 | 57 | @Test func tokenCoding() throws { 58 | let token = MockToken( 59 | header: JSONWebTokenHeader(algorithm: .ES256), 60 | payload: MockPayload(iss: nil, sub: nil, aud: nil, jti: nil, nbf: nil, iat: nil, exp: nil, customClaim: "claim") 61 | ) 62 | 63 | let mockSig = Data("signature!".utf8) 64 | 65 | let output = try token.encode { algo, data in 66 | #expect(algo == .ES256) 67 | 68 | return mockSig 69 | } 70 | 71 | let decoded = try MockToken(encodedString: output) { algo, message, signature in 72 | #expect(algo == .ES256) 73 | 74 | return signature == mockSig 75 | } 76 | 77 | #expect(decoded == token) 78 | } 79 | 80 | @Test func tokenDecode() throws { 81 | let tokenData = """ 82 | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJjdXN0b21DbGFpbSI6ImNsYWltIn0.Gpk1tPiut0i6rG9fXWI1cNh61me3qa8bTcEDMSwmD2I 83 | """ 84 | let token = try MockToken(encodedString: tokenData) { _, _, _ in 85 | return true 86 | } 87 | 88 | #expect(token.header.algorithm == .HS256) 89 | #expect(token.payload.customClaim == "claim") 90 | #expect(token.payload.iat == Date(timeIntervalSince1970: 1516239022)) 91 | } 92 | } 93 | 94 | #if canImport(CryptoKit) 95 | import CryptoKit 96 | 97 | extension JSONWebTokenTests { 98 | @Test func p256Signing() throws { 99 | let key = P256.Signing.PrivateKey() 100 | 101 | let token = MockToken( 102 | header: JSONWebTokenHeader(algorithm: .ES256), 103 | payload: MockPayload(iss: nil, sub: nil, aud: nil, jti: nil, nbf: nil, iat: nil, exp: nil, customClaim: "claim") 104 | ) 105 | 106 | let output = try token.encode(with: key) 107 | 108 | let decoded = try MockToken(encodedString: output, key: key.publicKey) 109 | 110 | #expect(decoded == token) 111 | } 112 | 113 | @Test func tokenWithWebKey() throws { 114 | let key = P256.Signing.PrivateKey() 115 | 116 | let webKey = JSONWebKey(p256Key: key.publicKey) 117 | 118 | let token = MockToken( 119 | header: JSONWebTokenHeader(algorithm: .ES256, jwk: webKey), 120 | payload: MockPayload(iss: nil, sub: nil, aud: nil, jti: nil, nbf: nil, iat: nil, exp: nil, customClaim: "claim") 121 | ) 122 | 123 | let output = try token.encode(with: key) 124 | 125 | let decoded = try MockToken(encodedString: output, key: key.publicKey) 126 | 127 | #expect(decoded == token) 128 | } 129 | 130 | @Test func hs256Signing() throws { 131 | let key = SymmetricKey(data: Data("thekey".utf8)) 132 | 133 | let token = MockToken( 134 | header: JSONWebTokenHeader(algorithm: .HS256, type: "JWT"), 135 | payload: MockPayload( 136 | iss: nil, 137 | sub: "1234567890", 138 | aud: nil, 139 | jti: nil, 140 | nbf: nil, 141 | iat: Date(timeIntervalSince1970: 1516239022), 142 | exp: nil, 143 | customClaim: "claim" 144 | ) 145 | ) 146 | 147 | let encoded = try token.encode(with: key) 148 | 149 | let tokenData = """ 150 | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjdXN0b21DbGFpbSI6ImNsYWltIiwiaWF0IjoxNTE2MjM5MDIyLCJzdWIiOiIxMjM0NTY3ODkwIn0.qfNgobhscI9_XCANUKYW0pbo1wf-wzdJ2JWAPFy-Sek 151 | """ 152 | 153 | #expect(encoded == tokenData) 154 | 155 | let decoded = try MockToken(encodedString: tokenData, key: key) 156 | 157 | #expect(decoded.header.algorithm == .HS256) 158 | #expect(decoded.payload.customClaim == "claim") 159 | } 160 | 161 | @Test func hs384Signing() throws { 162 | let key = SymmetricKey(data: Data("thekey".utf8)) 163 | 164 | let tokenData = """ 165 | eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJjdXN0b21DbGFpbSI6ImNsYWltIiwiaWF0IjoxNTE2MjM5MDIyLCJzdWIiOiIxMjM0NTY3ODkwIn0.7POATYQLX8CgQL5jyZPzl-O1dhuzcpyxhgYTVOJESJl7x-4JD0QnePMl6sdHDatW 166 | """ 167 | 168 | let token = try MockToken(encodedString: tokenData, key: key) 169 | 170 | #expect(token.header.algorithm == .HS384) 171 | #expect(token.payload.customClaim == "claim") 172 | } 173 | 174 | @Test func hs512Signing() throws { 175 | let key = SymmetricKey(data: Data("thekey".utf8)) 176 | 177 | let tokenData = """ 178 | eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJjdXN0b21DbGFpbSI6ImNsYWltIiwiaWF0IjoxNTE2MjM5MDIyLCJzdWIiOiIxMjM0NTY3ODkwIn0.OVuT-eJQOrYLElNPpMJOf3iHcSgXEj9FJkh_C0hd8g9ufWdcvXxayhqgjIcckOJ3WNSkEMOATUUWiO06AujC_A 179 | """ 180 | 181 | let token = try MockToken(encodedString: tokenData, key: key) 182 | 183 | #expect(token.header.algorithm == .HS512) 184 | #expect(token.payload.customClaim == "claim") 185 | } 186 | } 187 | #endif 188 | --------------------------------------------------------------------------------