├── .github └── workflows │ └── test.yml ├── .gitignore ├── .swiftpm └── xcode │ ├── package.xcworkspace │ ├── contents.xcworkspacedata │ ├── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcuserdata │ │ └── amlug.xcuserdatad │ │ └── UserInterfaceState.xcuserstate │ └── xcuserdata │ └── amlug.xcuserdatad │ └── xcschemes │ └── xcschememanagement.plist ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── JWTMiddleware │ └── JWTMiddleware.swift └── Tests └── JWTMiddlewareTests └── JWTMiddlewareTests.swift /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | - pull_request 4 | jobs: 5 | #jwtmiddleware_macos: 6 | # runs-on: macos-latest 7 | # env: 8 | # DEVELOPER_DIR: /Applications/Xcode_11.4_beta.app/Contents/Developer 9 | # steps: 10 | # - uses: actions/checkout@v2 11 | # - run: brew install vapor/tap/vapor-beta 12 | # - run: xcrun swift test --enable-test-discovery --sanitize=thread 13 | jwtmiddleware_xenial: 14 | container: 15 | image: vapor/swift:5.2-xenial 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v2 19 | - run: swift test --enable-test-discovery --sanitize=thread 20 | jwtmiddleware_bionic: 21 | container: 22 | image: vapor/swift:5.2-bionic 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v2 26 | - run: swift test --enable-test-discovery --sanitize=thread 27 | 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | /.swiftpm 6 | Package.resolved 7 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcuserdata/amlug.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skelpo/JWTMiddleware/1297f4104ff0562711c39bcdaa102fbc600339ab/.swiftpm/xcode/package.xcworkspace/xcuserdata/amlug.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /.swiftpm/xcode/xcuserdata/amlug.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | JWTMiddleware.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | SuppressBuildableAutocreation 14 | 15 | JWTMiddleware 16 | 17 | primary 18 | 19 | 20 | JWTMiddlewareTests 21 | 22 | primary 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Skelpo Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "JWTMiddleware", 6 | platforms: [ 7 | .macOS(.v10_15) 8 | ], 9 | products: [ 10 | .library(name: "JWTMiddleware", targets: ["JWTMiddleware"]), 11 | ], 12 | dependencies: [ 13 | .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"), 14 | .package(url: "https://github.com/vapor/jwt.git", from: "4.0.0"), 15 | ], 16 | targets: [ 17 | .target(name: "JWTMiddleware", dependencies: [ 18 | .product(name: "Vapor", package: "vapor"), 19 | .product(name: "JWT", package: "jwt"), 20 | ]), 21 | .testTarget(name: "JWTMiddlewareTests", dependencies: [ 22 | .byName(name: "JWTMiddleware"), 23 | .product(name: "XCTVapor", package: "vapor"), 24 | ]) 25 | ] 26 | ) 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JWTMiddleware 2 | 3 | Handles authentication and authorization of models through JWT tokens by themselves or mixed with other authentication methods. 4 | 5 | ## Install 6 | 7 | Add this line to your manifest's `dependencies` array: 8 | 9 | ```swift 10 | .package(url: "https://github.com/skelpo/JWTMiddleware.git", from: "0.6.1") 11 | ``` 12 | 13 | And add `JWTMiddleware` to all the target dependency arrays you want to access the package in. 14 | 15 | Complete the installation by running `vapor update` or `swift package update`. 16 | 17 | ## Modules 18 | 19 | There are currently 2 modules in the JWTMiddleware package; `JWTMiddleware` and `JWTAuthenticatable`. 20 | 21 | The `JWTMiddleware` module contains middleware for request authentication/authorization and helpers for getting data stored in the request by the middleware. 22 | 23 | The `JWTAuthenticatable` module holds protocols that allow a type to be authenticated/authorized in the middleware declared in the `JWTMiddleware` module. 24 | 25 | ## JWTMiddleware 26 | 27 | The JWTMiddleware module exports the following types: 28 | 29 | - `JWTAuthenticatableMiddlware`: 30 | 31 | Handles authenticating/authorizing a model conforming to `JWTAuthenticatable` using data pulled from a request. 32 | 33 | When a request is passed though the middleware, it will first check to see if the specified model is already authenticated. If it is not, it will try to get data to authenticate the model by calling the static `authBody(from:)` method. If it successfully authenticates, it will get the model's access token by calling `accessToken(on:)`, then store both the token and the authenticated model in the request for accessing later. If `nil` is return from `authBody(from:)`, then we try to authenticate using data from the `Authorization: Bearer ...` header. If authentication succeeds, we will store both the token fetched from the header and the authenticated model in the request. 34 | 35 | You can register the middleware to a route group as shown below. 36 | 37 | ```swift 38 | let auth = route.group(JWTAuthenticatableMiddlware()) 39 | ``` 40 | 41 | - `JWTVerificationMiddleware`: 42 | 43 | Gets the value from the `Authorization: Bearer ...` header, verifies it with the specified payload type, and stores it in the request for later. 44 | 45 | ```swift 46 | route.group(JWTVerificationMiddleware()) 47 | ``` 48 | - `RouteRestrictionMiddleware`: 49 | 50 | Restricts access to routes based on a user's permission level (i.e. Admin, Moderator, Standard, etc.) 51 | 52 | ```swift 53 | route.group(RouteRestrictionMiddleware( 54 | restrictions: [ 55 | RouteRestriction(.DELETE, at: "users", User.parameter, allowed: [.admin, .moderator]), 56 | ... 57 | ], 58 | parameters: [User.routingSlug: User.resolveParameter] 59 | )) 60 | ``` 61 | 62 | You must add custom parameter types used due to the way the request's URI and the restrictions path components are checked. Default parameter types are added by default. 63 | 64 | If a user is authenticated via middleware before `RouteRestrictionMiddleware`, the middleware will use that user's ID to check against the ID in the JWT payload we checking a request. 65 | 66 | - `RouteRestriction`: 67 | 68 | A restriction constraint for a `RouteRestrictionMiddleware` instance. The initializer takes in an optional method, a path, and valid permission levels for that path. If the method is `nil`, any method for the given path will be restricted. 69 | 70 | ```swift 71 | RouteRestriction(.GET, at: "dashboard", "user", User.parameter, "tickets", allowed: [.admin]) 72 | ``` 73 | 74 | - `PermissionedUserPayload`: 75 | 76 | Extends `IdentifiableJWTPayload` adding a 77 | 78 | ## JWTAuthenticatable 79 | 80 | The JWTAuthenticatable module exports the following types: 81 | 82 | - `IdentifiableJWTPayload`: 83 | 84 | Represents a JWT payload with an `id` value. This is used by the `BasicJWTAuthenticatable` to access a model from the database based on its `id` property. 85 | 86 | - `JWTAuthenticatable`: 87 | 88 | A model that can be authorized with a JWT payload and authenticated with an unspecified type that is defined by the implementing type. 89 | 90 | This protocol requires the following types/properties/methods: 91 | 92 | - `associatedtype AuthBody`: Used for authentication of the model. 93 | - `associatedtype Payload: IdentifiableJWTPayload`: A type that the payload of a JWT token can be converted to. This type is used to authorize requests. 94 | 95 | - `accessToken(on request: Request) throws -> Future`: This method should create a payload for a JWT token that will later be used to authorize the model. 96 | - `static authBody(from request: Request)throws -> AuthBody?`: Gets data from a request that can be used to authenticate a model 97 | - `static authenticate(from payload: Payload, on request: Request)throws -> Future`: Verifies the payload passed in and gets an instance of the model based on the payload's contents. 98 | - `static authenticate(from body: AuthBody, on request: Request)throws -> Future`: Gets a model and checks it against the contents of the `body` parameter passed in. 99 | 100 | - `BasicJWTAuthenticatable`: 101 | 102 | Implements `JWTAuthenticatable` methods for authenticating with an id/password combination. The ID should either be a username or email. 103 | 104 | The `authBody` method implementation gets the name of the property referenced by the `usernameKey` key-path. It will then extract the values from the request body with the key from `usernameKey` and `"password"`. 105 | 106 | The payload authorization method simply finds the instance of the model stored in the database with an ID equal to the `id` property in the payload. 107 | 108 | The `AuthBody` authentication method fetches the first user from the database with a `usernameKey` property equal to the `body.username` value. It then checks to see if the model's password hash is equal to the `body.password` value, using BCrypt verification. 109 | 110 | This protocols requires the following type/properties: 111 | 112 | - `associatedtype Payload: IdentifiableJWTPayload`: A type that the payload of a JWT token can be converted to. This type is used to authorize requests. 113 | - `usernameKey: KeyPath`: The key-path for the property that will be checked against the `body.username` value during authentication. This will usually be either an email or username. This property can be either a variable or constant. 114 | - `var password: String` The hashed password of the model, used to verify the request'c credibility. This properties value _must_ be hash using BCrypt. 115 | 116 | This module also adds some helper methods to the `Request` type: 117 | 118 | - `accessToken()throws -> String`: Gets the value of the `Authorization: Bearer ...` header. 119 | - `payload(as payloadType: Payload.Type = Payload.self)throws -> Payload`: Gets the payload of a JWT token the was previously stored in the request by a middleware. 120 | - `payloadData(storedAs stored: Payload.Type, convertedTo objectType: Object.Type = Object.self)throws -> Object`: Gets the payload of a JWT token stored in the request and converts it to a different type. 121 | -------------------------------------------------------------------------------- /Sources/JWTMiddleware/JWTMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import JWT 3 | 4 | public final class JWTMiddleware: Middleware { 5 | public init() {} 6 | 7 | public func respond(to request: Request, chainingTo next: Responder) -> EventLoopFuture { 8 | 9 | guard let token = request.headers.bearerAuthorization?.token.utf8 else { 10 | return request.eventLoop.makeFailedFuture(Abort(.unauthorized, reason: "Missing authorization bearer header")) 11 | } 12 | 13 | do { 14 | request.payload = try request.jwt.verify(Array(token), as: T.self) 15 | } catch let JWTError.claimVerificationFailure(name: name, reason: reason) { 16 | request.logger.error("JWT Verification Failure: \(name), \(reason)") 17 | return request.eventLoop.makeFailedFuture(JWTError.claimVerificationFailure(name: name, reason: reason)) 18 | } catch let error { 19 | return request.eventLoop.makeFailedFuture(error) 20 | } 21 | 22 | return next.respond(to: request) 23 | } 24 | 25 | } 26 | 27 | extension Request { 28 | private struct PayloadKey: StorageKey { 29 | typealias Value = JWTPayload 30 | } 31 | 32 | var payload: JWTPayload { 33 | get { self.storage[PayloadKey.self]! } 34 | set { self.storage[PayloadKey.self] = newValue } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Tests/JWTMiddlewareTests/JWTMiddlewareTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import JWT 3 | import Vapor 4 | import XCTVapor 5 | import CNIOBoringSSL 6 | @testable import JWTMiddleware 7 | @testable import JWTKit 8 | 9 | struct TestPayload: JWTPayload { 10 | let id: UUID 11 | let exp: TimeInterval 12 | 13 | init(id: UUID, exp: TimeInterval) { 14 | self.id = id 15 | self.exp = exp 16 | } 17 | 18 | func verify(using signer: JWTSigner) throws { 19 | _ = SubjectClaim(value: self.id.uuidString) // Nothing to verify here. 20 | try ExpirationClaim(value: Date(timeIntervalSince1970: self.exp)).verifyNotExpired() 21 | } 22 | } 23 | 24 | final class JWTMiddlewareTests: XCTestCase { 25 | 26 | var tester: Application! 27 | 28 | override func setUpWithError() throws { 29 | // CryptoKit only generates EC keys and I don't know how to turn the raw representation into JWKS. 30 | var exp: BIGNUM = .init(); CNIOBoringSSL_BN_set_u64(&exp, 0x10001) 31 | var rsa: RSA = .init(); CNIOBoringSSL_RSA_generate_key_ex(&rsa, 4096, &exp, nil) 32 | 33 | let dBytes: [UInt8] = .init(unsafeUninitializedCapacity: Int(CNIOBoringSSL_BN_num_bytes(rsa.d))) { $1 = CNIOBoringSSL_BN_bn2bin(rsa.d, $0.baseAddress!) } 34 | let nBytes: [UInt8] = .init(unsafeUninitializedCapacity: Int(CNIOBoringSSL_BN_num_bytes(rsa.n))) { $1 = CNIOBoringSSL_BN_bn2bin(rsa.n, $0.baseAddress!) } 35 | struct LocalJWKS: Codable { 36 | struct LocalJWK: Codable { let kty, d, e, n, use, kid, alg: String } 37 | let keys: [LocalJWK] 38 | } 39 | let keyset = LocalJWKS(keys: [.init(kty: "RSA", d: String(bytes: dBytes.base64URLEncodedBytes(), encoding: .utf8)!, e: "AQAB", n: String(bytes: nBytes.base64URLEncodedBytes(), encoding: .utf8)!, use: "sig", kid: "jwttest", alg: "RS256")]) 40 | let json = try JSONEncoder().encode(keyset) 41 | 42 | tester = Application(.testing) 43 | try tester.jwt.signers.use(jwksJSON: String(data: json, encoding: .utf8)!) 44 | } 45 | 46 | override func tearDownWithError() throws { 47 | tester?.shutdown() 48 | } 49 | 50 | func testPayloadValidationUnexpired() throws { 51 | let testPayload = TestPayload(id: UUID(), exp: Date(timeIntervalSinceNow: 10.0).timeIntervalSince1970) 52 | 53 | tester.middleware.use(JWTMiddleware()) 54 | tester.get("hello") { _ in "world" } 55 | 56 | let token = try tester.jwt.signers.sign(testPayload, kid: "jwttest") 57 | 58 | _ = try XCTUnwrap(tester.testable(method: .inMemory).test(.GET, "/hello", headers: ["Authorization": "Bearer \(token)"]) { res in 59 | XCTAssertEqual(res.body.string, "world") 60 | }) 61 | } 62 | 63 | func testPayloadValidationExpired() throws { 64 | let testPayload = TestPayload(id: UUID(), exp: Date(timeIntervalSinceNow: -10.0).timeIntervalSince1970) 65 | 66 | tester.middleware.use(JWTMiddleware()) 67 | tester.get("hello") { _ in "world" } 68 | 69 | let token = try tester.jwt.signers.sign(testPayload, kid: "jwttest") 70 | 71 | _ = try XCTUnwrap(tester.testable(method: .inMemory).test(.GET, "/hello", headers: ["Authorization": "Bearer \(token)"]) { res in 72 | XCTAssertEqual(res.status, .unauthorized) 73 | 74 | struct JWTErrorResponse: Codable { 75 | let error: Bool 76 | let reason: String 77 | } 78 | 79 | guard let content = try? XCTUnwrap(res.content.decode(JWTErrorResponse.self)) else { 80 | return 81 | } 82 | XCTAssertEqual(content.error, true) 83 | XCTAssertEqual(content.reason, "exp claim verification failed: expired") 84 | }) 85 | } 86 | } 87 | --------------------------------------------------------------------------------