├── .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 |
--------------------------------------------------------------------------------