├── .codebeatignore ├── .codecov.yml ├── .github └── workflows │ ├── documentation.yml │ └── test.yml ├── .gitignore ├── .swiftlint.yml ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── Keychain │ ├── AuthenticationRequest.swift │ ├── AuthenticationResponse.swift │ ├── Extensions │ ├── Application+JWTKeychain.swift │ └── Request+JWTKeychain.swift │ ├── ForgotPasswordRequest.swift │ ├── KeychainConfig.swift │ ├── KeychainPayload.swift │ ├── LoginRequest.swift │ ├── RegisterRequest.swift │ ├── ResetPasswordRequest.swift │ └── Utilities │ └── Storage+orSetDefault.swift └── Tests ├── KeychainTests ├── Controllers │ └── UserController.swift ├── Errors │ └── TestError.swift ├── KeychainTests.swift ├── Models │ ├── Entities │ │ └── User.swift │ ├── JWTPayloads │ │ └── UserJWTPayload.swift │ ├── KeychainConfigs │ │ ├── UserAccessKeychainConfig.swift │ │ ├── UserRefreshKeychainConfig.swift │ │ └── UserResetKeychainConfig.swift │ ├── Requests │ │ ├── UserForgotPasswordRequest.swift │ │ ├── UserLoginRequest.swift │ │ ├── UserRegisterRequest.swift │ │ └── UserResetPasswordRequest.swift │ └── Responses │ │ ├── RefreshTokenResponse.swift │ │ ├── TestAuthenticationResponse.swift │ │ └── UserResponse.swift ├── Test Helpers │ ├── TestHasher.swift │ ├── TestJWTAlgorithm.swift │ └── TestValidatorResultFailure.swift └── Utilities │ ├── Application+mail.swift │ ├── Application+users.swift │ └── Request+users.swift └── LinuxMain.swift /.codebeatignore: -------------------------------------------------------------------------------- 1 | Public/** 2 | Resources/Assets/** 3 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | range: "0...100" 3 | ignore: 4 | - "Tests" 5 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v1 13 | - name: Generate Documentation 14 | uses: SwiftDocOrg/swift-doc@master 15 | with: 16 | inputs: "Sources" 17 | module-name: Keychain 18 | output: "Documentation" 19 | - name: Upload Documentation to Wiki 20 | uses: SwiftDocOrg/github-wiki-publish-action@v1 21 | with: 22 | path: "Documentation" 23 | env: 24 | GH_PERSONAL_ACCESS_TOKEN: ${{ secrets.WIKI_ACCESS_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | linux: 9 | runs-on: ubuntu-latest 10 | container: swift:5.2-bionic 11 | steps: 12 | - name: Check out code 13 | uses: actions/checkout@v2 14 | - name: Run tests with Thread Sanitizer 15 | run: swift test --enable-test-discovery --sanitize=thread 16 | macOS: 17 | runs-on: macos-latest 18 | steps: 19 | - name: Select latest available Xcode 20 | uses: maxim-lobanov/setup-xcode@v1 21 | with: 22 | xcode-version: latest 23 | - name: Check out code 24 | uses: actions/checkout@v2 25 | - name: Run tests with Thread Sanitizer 26 | run: swift test --enable-test-discovery --sanitize=thread -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Packages 2 | .build 3 | .idea 4 | .DS_Store 5 | *.xcodeproj 6 | DerivedData/ 7 | Package.resolved 8 | .swiftpm 9 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | included: 2 | - Sources 3 | function_body_length: 4 | warning: 60 5 | identifier_name: 6 | min_length: 7 | warning: 2 8 | line_length: 100 9 | disabled_rules: 10 | - opening_brace 11 | colon: 12 | flexible_right_spacing: true 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016-2018 Nodes 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 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "keychain", 7 | platforms: [ 8 | .macOS(.v10_15) 9 | ], 10 | products: [ 11 | .library(name: "Keychain", targets: ["Keychain"]) 12 | ], 13 | dependencies: [ 14 | .package(url: "https://github.com/vapor/jwt.git", from: "4.0.0"), 15 | .package(url: "https://github.com/vapor/vapor.git", from: "4.50.0"), 16 | .package(url: "https://github.com/nodes-vapor/submissions.git", from: "3.0.0-rc") 17 | ], 18 | targets: [ 19 | .target( 20 | name: "Keychain", 21 | dependencies: [ 22 | .product(name: "JWT", package: "jwt"), 23 | .product(name: "Vapor", package: "vapor"), 24 | .product(name: "Submissions", package: "submissions"), 25 | ] 26 | ), 27 | .testTarget(name: "KeychainTests", dependencies: [ 28 | .target(name:"Keychain"), 29 | .product(name: "XCTVapor", package: "vapor"), 30 | ]) 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Keychain ⛓ 2 | [![Swift Version](https://img.shields.io/badge/Swift-5.2-brightgreen.svg)](http://swift.org) 3 | [![Vapor Version](https://img.shields.io/badge/Vapor-4-30B6FC.svg)](http://vapor.codes) 4 | ![tests](https://github.com/nodes-vapor/keychain/workflows/test/badge.svg) 5 | [![Readme Score](http://readme-score-api.herokuapp.com/score.svg?url=https://github.com/nodes-vapor/keychain)](http://clayallsopp.github.io/readme-score?url=https://github.com/nodes-vapor/keychain) 6 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/nodes-vapor/keychain/main/LICENSE) 7 | 8 | Keychain adds a complete and customizable user authentication system to your API project. 9 | 10 | ## 📦 Installation 11 | 12 | Update your `Package.swift` file. 13 | 14 | ```swift 15 | .package(url: "https://github.com/nodes-vapor/keychain.git", from: "2.0.0") 16 | ``` 17 | ```swift 18 | targets: [ 19 | .target( 20 | name: "App", 21 | dependencies: [ 22 | ... 23 | .product(name: "Keychain", package: "keychain"), 24 | ] 25 | ), 26 | ... 27 | ] 28 | ``` 29 | 30 | ## Usage 31 | These are the steps required to use Keychain in your project. 32 | 1. Define a Payload that conforms to the `KeychainPayload` protocol 33 | 2. Create `KeychainConfig` objects for the key types you would like to use 34 | 3. Configure your `Keychain` using a `Signer` and the `KeychainConfig` objects defined in step 2 35 | 4. Actually start using your `Keychain` 36 | 37 | Time to look at each step in detail. 38 | 39 | ### Define a Payload 40 | 41 | Your payload must conform to the `KeychainPayload` protocol, meaning that it must contain: 42 | - `init(expirationDate: Date, user: User) throws` 43 | - `func findUser(request: Request) -> EventLoopFuture` which is where you do a search for the user you were presented in the `init` method 44 | - `func verify(using signer: JWTSigner) throws` which will verify that your token is still valid 45 | 46 | Furthermore you need to tell your `KeychainPayload` what its `associatedtype` for `User` translates to. 47 | 48 | Here is an example that uses elements from a JWT token and verifies that the expiration (`exp`) claim is not expired. Note that `findUser` in this case only returns a test user. In real life you probably want to do a lookup somewhere where users are stored. 49 | 50 | ```swift 51 | import JWT 52 | import Keychain 53 | import Vapor 54 | 55 | struct UserJWTPayload: KeychainPayload { 56 | let exp: ExpirationClaim 57 | let sub: SubjectClaim 58 | 59 | init(expirationDate: Date, user: User) { 60 | self.exp = .init(value: expirationDate) 61 | self.sub = .init(value: user.id) 62 | } 63 | 64 | func findUser(request: Request) -> EventLoopFuture { 65 | request.eventLoop.future(request.testUser).unwrap(or: TestError.userNotFound) 66 | } 67 | 68 | func verify(using signer: JWTSigner) throws { 69 | try exp.verifyNotExpired() 70 | } 71 | } 72 | ``` 73 | 74 | ### Create `KeychainConfig` Objects 75 | 76 | Your `KeychainConfig` objects must contain: 77 | - an identifier (eg: access, refresh or reset): `jwkIdentifier` 78 | - an `expirationTimeInterval` 79 | 80 | And you need to connect your `KeychainConfig` with the `KeychainPayload` you defined in step 1 (the `KeychainConfig` has a `typealias` for a `KeychainPayload`). 81 | 82 | Here is an example creating three `KeychainConfig` objects: 83 | - A `UserAccessKeychainConfig` with the identifier "access" and an `expirationTimeInterval` of 300 seconds 84 | - A `UserRefreshKeychainConfig` with the identifier "refresh" and an `expirationTimeInterval` of 600 seconds 85 | - A `UserResetKeychainConfig` with the identifier "reset" and an `expirationTimeInterval` of 400 seconds 86 | 87 | ```swift 88 | import JWT 89 | import Keychain 90 | 91 | struct UserAccessKeychainConfig: KeychainConfig, Equatable { 92 | typealias JWTPayload = UserJWTPayload 93 | 94 | static var jwkIdentifier: JWKIdentifier = "access" 95 | 96 | let expirationTimeInterval: TimeInterval = 300 97 | } 98 | 99 | struct UserRefreshKeychainConfig: KeychainConfig, Equatable { 100 | typealias JWTPayload = UserJWTPayload 101 | 102 | static var jwkIdentifier: JWKIdentifier = "refresh" 103 | 104 | let expirationTimeInterval: TimeInterval = 600 105 | } 106 | 107 | struct UserResetKeychainConfig: KeychainConfig, Equatable { 108 | typealias JWTPayload = UserJWTPayload 109 | 110 | static var jwkIdentifier: JWKIdentifier = "reset" 111 | 112 | let expirationTimeInterval: TimeInterval = 400 113 | } 114 | ``` 115 | 116 | ### Configure your `Keychain` 117 | 118 | Time to tie it all together! In your `configure.swift` you can add multiple `KeychainConfig` objects as seen here: 119 | 120 | ```swift 121 | app.keychain.configure( 122 | signer: .hs256(key: YourKeyGoesHere...ProbablyReadFromSomeEnvironment), 123 | config: UserAccessKeychainConfig() 124 | ) 125 | app.keychain.configure( 126 | signer: JWTSigner( 127 | algorithm: TestJWTAlgorithm(name: UserRefreshKeychainConfig.jwkIdentifier.string) 128 | ), 129 | config: UserRefreshKeychainConfig() 130 | ) 131 | app.keychain.configure( 132 | signer: JWTSigner( 133 | algorithm: TestJWTAlgorithm(name: UserResetKeychainConfig.jwkIdentifier.string) 134 | ), 135 | config: UserResetKeychainConfig() 136 | ) 137 | ``` 138 | 139 | Note the `signer` parameter. You can use one of the built-in signers as in the first example where we use the `.hs256` signer with a key. Alternatively, you can provide your own signer as it is done in the last two examples. 140 | 141 | ### Actually start using your `Keychain` 142 | 143 | With all the setup out of the way, it is time to kick back and take advantage of `Keychain`. You can now use the `UserAccessKeychainConfig`, `UserRefreshKeychainConfig` and `UserResetKeychainConfig` objects that you created previously to generate JWT tokens by calling the `makeToken(on:, currentDate:)` 144 | 145 | Here is an example on how to generate a new `refreshToken`. 146 | 147 | ```swift 148 | import Keychain 149 | 150 | struct UserController { 151 | let currentDate: () -> Date 152 | 153 | ... 154 | 155 | func refreshToken(request: Request) throws -> Response { 156 | let token = try UserRefreshKeychainConfig.makeToken(on: request, currentDate: currentDate()) 157 | 158 | // here we encode the token string as JSON but you might include your token in a struct 159 | // conforming to `Content` 160 | let response = Response() 161 | try response.content.encode(token, as: .json) 162 | return response 163 | } 164 | } 165 | ``` 166 | 167 | ## 🏆 Credits 168 | 169 | This package is developed and maintained by the Vapor team at [Monstarlab](https://monstar-lab.com/global/). 170 | 171 | ## 📄 License 172 | 173 | This package is open-sourced software licensed under the [MIT license](http://opensource.org/licenses/MIT) 174 | -------------------------------------------------------------------------------- /Sources/Keychain/AuthenticationRequest.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public protocol AuthenticationRequest { 4 | typealias User = AccessKeychainConfig.JWTPayload.User 5 | 6 | associatedtype AccessKeychainConfig: KeychainConfig 7 | associatedtype RefreshKeychainConfig: KeychainConfig 8 | where RefreshKeychainConfig.JWTPayload.User == User 9 | } 10 | 11 | public extension AuthenticationRequest { 12 | static func authenticationResponse( 13 | for user: User, 14 | on request: Request, 15 | currentDate: Date = Date() 16 | ) throws -> AuthenticationResponse { 17 | try .init( 18 | user: user, 19 | accessToken: AccessKeychainConfig 20 | .makeToken(for: user, on: request, currentDate: currentDate), 21 | refreshToken: RefreshKeychainConfig 22 | .makeToken(for: user, on: request, currentDate: currentDate) 23 | ) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Keychain/AuthenticationResponse.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public struct AuthenticationResponse { 4 | public let user: User 5 | public let accessToken: String 6 | public let refreshToken: String 7 | 8 | public init( 9 | user: User, 10 | accessToken: String, 11 | refreshToken: String 12 | ) { 13 | self.user = user 14 | self.accessToken = accessToken 15 | self.refreshToken = refreshToken 16 | } 17 | 18 | public func map(_ transformUser: (User) throws -> T) rethrows -> AuthenticationResponse { 19 | .init(user: try transformUser(user), accessToken: accessToken, refreshToken: refreshToken) 20 | } 21 | 22 | public func flatMap( 23 | _ transformUser: (User) -> EventLoopFuture 24 | ) -> EventLoopFuture> { 25 | transformUser(user).map { 26 | .init(user: $0, accessToken: self.accessToken, refreshToken: self.refreshToken) 27 | } 28 | } 29 | } 30 | 31 | extension AuthenticationResponse: Codable where User: Codable {} 32 | extension AuthenticationResponse: 33 | Content, RequestDecodable, ResponseEncodable, AsyncRequestDecodable, AsyncResponseEncodable where User: Content {} 34 | -------------------------------------------------------------------------------- /Sources/Keychain/Extensions/Application+JWTKeychain.swift: -------------------------------------------------------------------------------- 1 | import JWT 2 | import Vapor 3 | 4 | public extension Application { 5 | struct Keychain { 6 | fileprivate struct Key: StorageKey { 7 | typealias Value = Keychain 8 | } 9 | 10 | fileprivate let application: Application 11 | 12 | public func config(for jwkIdentifiableType: T.Type) -> T { 13 | application.storage[JWKStorageKey.self]! 14 | } 15 | 16 | public func configure(signer: JWTSigner, config: T) { 17 | application.jwt.signers.use(signer, kid: T.jwkIdentifier) 18 | application.storage[JWKStorageKey.self] = config 19 | } 20 | } 21 | 22 | var keychain: Keychain { 23 | get { 24 | storage[Keychain.Key.self, orSetDefault: Keychain(application: self)] 25 | } 26 | set { 27 | storage[Keychain.Key.self] = newValue 28 | } 29 | } 30 | } 31 | 32 | private struct JWKStorageKey: StorageKey { 33 | typealias Value = Config 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Keychain/Extensions/Request+JWTKeychain.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public extension Request { 4 | struct Keychain { 5 | fileprivate let request: Request 6 | 7 | public func config(for jwkIdentifiableType: T.Type) -> T { 8 | request.application.keychain.config(for: jwkIdentifiableType) 9 | } 10 | } 11 | 12 | var keychain: Keychain { .init(request: self) } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/Keychain/ForgotPasswordRequest.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Submissions 3 | 4 | public protocol ForgotPasswordRequest: ValidatableRequest { 5 | associatedtype Config: KeychainConfig 6 | 7 | static func sendToken( 8 | _ token: String, 9 | user: Config.JWTPayload.User, 10 | config: Config, 11 | request: Request 12 | ) -> EventLoopFuture 13 | 14 | func findUser(request: Request) -> EventLoopFuture 15 | } 16 | 17 | public extension ForgotPasswordRequest { 18 | static func sendToken(on request: Request, currentDate: Date = Date()) -> EventLoopFuture { 19 | validated(on: request) 20 | .flatMap { $0.findUser(request: request) } 21 | .flatMap { user in 22 | guard let user = user else { 23 | // when no user could be found, skip the steps below but pretend that the 24 | // request was successful 25 | return request.eventLoop.future() 26 | } 27 | let config = request.keychain.config(for: Config.self) 28 | do { 29 | return sendToken( 30 | try config.makeToken(for: user, on: request, currentDate: currentDate), 31 | user: user, 32 | config: config, 33 | request: request 34 | ) 35 | } catch { 36 | return request.eventLoop.makeFailedFuture(error) 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/Keychain/KeychainConfig.swift: -------------------------------------------------------------------------------- 1 | import JWT 2 | import Vapor 3 | 4 | public protocol KeychainConfig { 5 | associatedtype JWTPayload: KeychainPayload 6 | static var jwkIdentifier: JWKIdentifier { get } 7 | var expirationTimeInterval: TimeInterval { get } 8 | } 9 | 10 | extension KeychainConfig where JWTPayload.User: Authenticatable { 11 | public static var authenticator: some JWTAuthenticator { Authenticator() } 12 | } 13 | 14 | extension KeychainConfig { 15 | public static func makeToken( 16 | for user: JWTPayload.User, 17 | on request: Request, 18 | currentDate: Date = Date() 19 | ) throws -> String { 20 | try request 21 | .keychain 22 | .config(for: Self.self) 23 | .makeToken(for: user, on: request, currentDate: currentDate) 24 | } 25 | 26 | public func makeToken( 27 | for user: JWTPayload.User, 28 | on request: Request, 29 | currentDate: Date = Date() 30 | ) throws -> String { 31 | try request.jwt.sign( 32 | JWTPayload( 33 | expirationDate: currentDate.addingTimeInterval(expirationTimeInterval), 34 | user: user 35 | ), 36 | kid: Self.jwkIdentifier 37 | ) 38 | } 39 | } 40 | 41 | extension KeychainConfig where JWTPayload.User: Authenticatable { 42 | public static func makeToken( 43 | on request: Request, 44 | currentDate: Date = Date() 45 | ) throws -> String { 46 | try makeToken(for: request.auth.require(), on: request, currentDate: currentDate) 47 | } 48 | 49 | public func makeToken( 50 | on request: Request, 51 | currentDate: Date = Date() 52 | ) throws -> String { 53 | try makeToken(for: request.auth.require(), on: request, currentDate: currentDate) 54 | } 55 | } 56 | 57 | struct Authenticator: JWTAuthenticator where T.JWTPayload.User: Authenticatable { 58 | func authenticate( 59 | jwt: T.JWTPayload, 60 | for request: Request 61 | ) -> EventLoopFuture { 62 | jwt.findUser(request: request).map(request.auth.login) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/Keychain/KeychainPayload.swift: -------------------------------------------------------------------------------- 1 | import JWT 2 | import Vapor 3 | 4 | public protocol KeychainPayload: JWTPayload { 5 | associatedtype User 6 | 7 | init(expirationDate: Date, user: User) throws 8 | func findUser(request: Request) -> EventLoopFuture 9 | } 10 | -------------------------------------------------------------------------------- /Sources/Keychain/LoginRequest.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Submissions 3 | 4 | public protocol LoginRequest: AuthenticationRequest, ValidatableRequest { 5 | typealias Model = User 6 | 7 | static var hashedPasswordKey: KeyPath { get } 8 | 9 | var password: String { get } 10 | 11 | func logIn(on request: Request) -> EventLoopFuture 12 | } 13 | 14 | public extension LoginRequest { 15 | static func logIn( 16 | on request: Request, 17 | errorOnWrongPassword: @escaping @autoclosure () -> Error, 18 | currentDate: Date = Date() 19 | ) -> EventLoopFuture> { 20 | validated(on: request).flatMap { loginRequest in 21 | loginRequest 22 | .logIn(on: request) 23 | .flatMap { user in 24 | request.password.async 25 | .verify(loginRequest.password, created: user[keyPath: hashedPasswordKey]) 26 | .flatMapThrowing { passwordsMatch in 27 | guard passwordsMatch else { throw errorOnWrongPassword() } 28 | 29 | return try authenticationResponse( 30 | for: user, 31 | on: request, 32 | currentDate: currentDate 33 | ) 34 | } 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/Keychain/RegisterRequest.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Submissions 3 | 4 | public protocol RegisterRequest: AuthenticationRequest, ValidatableRequest { 5 | typealias Model = User 6 | 7 | func register(on request: Request) -> EventLoopFuture 8 | } 9 | 10 | public extension RegisterRequest { 11 | static func register( 12 | on request: Request, 13 | currentDate: Date = Date() 14 | ) -> EventLoopFuture> { 15 | validated(on: request).flatMap { registerRequest in 16 | registerRequest.register(on: request) 17 | }.flatMapThrowing { user in 18 | try authenticationResponse(for: user, on: request, currentDate: currentDate) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Keychain/ResetPasswordRequest.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Submissions 3 | 4 | public protocol ResetPasswordRequest: ValidatableRequest { 5 | associatedtype Model 6 | 7 | static var hashedPasswordKey: ReferenceWritableKeyPath { get } 8 | 9 | var password: String { get } 10 | } 11 | 12 | public extension ResetPasswordRequest { 13 | static func updatePassword(for user: Model, on request: Request) -> EventLoopFuture { 14 | validated(on: request).flatMap { resetPasswordRequest in 15 | request 16 | .password 17 | .async 18 | .hash(resetPasswordRequest.password) 19 | .map { 20 | user[keyPath: Self.hashedPasswordKey] = $0 21 | return user 22 | } 23 | } 24 | } 25 | } 26 | 27 | public extension ResetPasswordRequest where Model: Authenticatable { 28 | static func updatePassword(on request: Request) -> EventLoopFuture { 29 | request.eventLoop 30 | .future(result: .init { try request.auth.require() }) 31 | .flatMap { updatePassword(for: $0, on: request)} 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/Keychain/Utilities/Storage+orSetDefault.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | extension Storage { 4 | subscript( 5 | _ key: Key.Type, 6 | orSetDefault default: @autoclosure () -> Key.Value 7 | ) -> Key.Value where Key: StorageKey { 8 | mutating get { 9 | guard let value = self[Key.self] else { 10 | let value = `default`() 11 | self[Key.self] = value 12 | return value 13 | } 14 | return value 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Tests/KeychainTests/Controllers/UserController.swift: -------------------------------------------------------------------------------- 1 | import Keychain 2 | import Vapor 3 | 4 | struct UserController { 5 | let currentDate: () -> Date 6 | 7 | func login(request: Request) -> EventLoopFuture> { 8 | UserLoginRequest 9 | .logIn( 10 | on: request, 11 | errorOnWrongPassword: TestError.incorrectCredentials, 12 | currentDate: currentDate() 13 | ).map { $0.map(UserResponse.init) } 14 | } 15 | 16 | func register(request: Request) -> EventLoopFuture> { 17 | UserRegisterRequest 18 | .register( 19 | on: request, 20 | currentDate: currentDate() 21 | ).map { 22 | request.testUser = $0.user 23 | return $0.map(UserResponse.init) 24 | } 25 | } 26 | 27 | func forgotPassword(request: Request) -> EventLoopFuture { 28 | UserForgotPasswordRequest 29 | .sendToken(on: request, currentDate: currentDate()) 30 | .transform(to: .accepted) 31 | } 32 | 33 | func resetPassword(request: Request) -> EventLoopFuture { 34 | UserResetPasswordRequest 35 | .updatePassword(on: request) 36 | .map { request.testUser = $0} 37 | .transform(to: .ok) 38 | } 39 | 40 | func refreshToken(request: Request) throws -> RefreshTokenResponse { 41 | .init(refreshToken: try UserRefreshKeychainConfig.makeToken(on: request, currentDate: currentDate())) 42 | } 43 | 44 | func me(request: Request) throws -> UserResponse { 45 | try .init(user: request.auth.require(User.self)) 46 | } 47 | } 48 | 49 | extension UserController: RouteCollection { 50 | func boot(routes: RoutesBuilder) throws { 51 | routes.post("login", use: login) 52 | routes.post("register", use: register) 53 | 54 | let password = routes.grouped("password") 55 | password.post("forgot", use: forgotPassword) 56 | password 57 | .grouped(UserResetKeychainConfig.authenticator) 58 | .post("reset", use: resetPassword) 59 | 60 | routes 61 | .grouped(UserRefreshKeychainConfig.authenticator) 62 | .post("token", use: refreshToken) 63 | routes 64 | .grouped(UserAccessKeychainConfig.authenticator) 65 | .get("me", use: me) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Tests/KeychainTests/Errors/TestError.swift: -------------------------------------------------------------------------------- 1 | enum TestError: Error { 2 | case incorrectCredentials 3 | case userNotFound 4 | } 5 | -------------------------------------------------------------------------------- /Tests/KeychainTests/KeychainTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Keychain 2 | import Vapor 3 | import JWT 4 | import XCTVapor 5 | 6 | final class KeychainTests: XCTestCase { 7 | var app: Application! 8 | 9 | override func setUp() { 10 | app = Application(.testing) 11 | app.keychain.configure( 12 | signer: JWTSigner( 13 | algorithm: TestJWTAlgorithm(name: UserAccessKeychainConfig.jwkIdentifier.string) 14 | ), 15 | config: UserAccessKeychainConfig() 16 | ) 17 | app.keychain.configure( 18 | signer: JWTSigner( 19 | algorithm: TestJWTAlgorithm(name: UserRefreshKeychainConfig.jwkIdentifier.string) 20 | ), 21 | config: UserRefreshKeychainConfig() 22 | ) 23 | app.keychain.configure( 24 | signer: JWTSigner( 25 | algorithm: TestJWTAlgorithm(name: UserResetKeychainConfig.jwkIdentifier.string) 26 | ), 27 | config: UserResetKeychainConfig() 28 | ) 29 | app.passwords.use(TestHasher.init) 30 | try! app.register(collection: UserController(currentDate: { Date.init(timeIntervalSince1970: 0) })) 31 | } 32 | 33 | override func tearDown() { 34 | app.shutdown() 35 | app = nil 36 | } 37 | 38 | func test_login() throws { 39 | app.users.testUser = .test 40 | try app.test(.POST, "login", beforeRequest: { request in 41 | try request.content.encode(["password": "secret"], as: .json) 42 | }) { response in 43 | XCTAssertEqual(response.status, .ok) 44 | 45 | // test user response 46 | let loginResponse = try response.content.decode(TestAuthenticationResponse.self) 47 | XCTAssertEqual(loginResponse.user, .init(name: "Ida")) 48 | 49 | // test access token 50 | let accessTokenPayload: UserJWTPayload = try app.jwt.signers 51 | .require(kid: UserAccessKeychainConfig.jwkIdentifier) 52 | .verify(loginResponse.accessToken) 53 | XCTAssertEqual(accessTokenPayload.sub, "userID") 54 | XCTAssertEqual(accessTokenPayload.exp.value.timeIntervalSince1970, 300) 55 | 56 | // test refresh token 57 | let refreshTokenPayload: UserJWTPayload = try app.jwt.signers 58 | .require(kid: UserRefreshKeychainConfig.jwkIdentifier) 59 | .verify(loginResponse.refreshToken) 60 | XCTAssertEqual(refreshTokenPayload.sub, "userID") 61 | XCTAssertEqual(refreshTokenPayload.exp.value.timeIntervalSince1970, 600) 62 | } 63 | } 64 | 65 | func test_login_includesValidation() throws { 66 | try app.test(.POST, "login?fail", beforeRequest: { request in 67 | try request.content.encode(["password": "secret"], as: .json) 68 | }) { response in 69 | XCTAssertEqual(response.status, .badRequest) 70 | let errorReason: String = try response.content.get(at: "reason") 71 | XCTAssertEqual(errorReason, "validation has failed") 72 | } 73 | } 74 | 75 | func test_register() throws { 76 | try app.test(.POST, "register", beforeRequest: { request in 77 | try request.content.encode(["name": "Ida", "password": "secret"], as: .json) 78 | }) { response in 79 | XCTAssertEqual(response.status, .ok) 80 | 81 | // test user response 82 | let loginResponse = try response.content.decode(TestAuthenticationResponse.self) 83 | XCTAssertEqual(loginResponse.user, .init(name: "Ida")) 84 | 85 | // test access token 86 | let accessTokenPayload: UserJWTPayload = try app.jwt.signers 87 | .require(kid: UserAccessKeychainConfig.jwkIdentifier) 88 | .verify(loginResponse.accessToken) 89 | XCTAssertEqual(accessTokenPayload.sub, "userID") 90 | XCTAssertEqual(accessTokenPayload.exp.value.timeIntervalSince1970, 300) 91 | 92 | // test refresh token 93 | let refreshTokenPayload: UserJWTPayload = try app.jwt.signers 94 | .require(kid: UserRefreshKeychainConfig.jwkIdentifier) 95 | .verify(loginResponse.refreshToken) 96 | XCTAssertEqual(refreshTokenPayload.sub, "userID") 97 | XCTAssertEqual(refreshTokenPayload.exp.value.timeIntervalSince1970, 600) 98 | } 99 | 100 | // test user is persisted 101 | let user = try XCTUnwrap(app.users.testUser) 102 | XCTAssertEqual(user.name, "Ida") 103 | XCTAssertNoThrow(try app.password.verify("secret", created: user.hashedPassword)) 104 | } 105 | 106 | func test_register_includesValidation() throws { 107 | try app.test(.POST, "register?fail", beforeRequest: { request in 108 | try request.content.encode(["name": "Ida", "password": "secret"], as: .json) 109 | }) { response in 110 | XCTAssertEqual(response.status, .badRequest) 111 | let errorReason: String = try response.content.get(at: "reason") 112 | XCTAssertEqual(errorReason, "validation has failed") 113 | } 114 | } 115 | 116 | func test_forgotPassword_ignoresUserNotFound() throws { 117 | try app.test(.POST, "password/forgot", beforeRequest: { request in 118 | try request.content.encode(["name": "Ida"], as: .json) 119 | }) { response in 120 | XCTAssertEqual(response.status, .accepted) 121 | } 122 | } 123 | 124 | func test_forgotPassword() throws { 125 | app.users.testUser = .test 126 | try app.test(.POST, "password/forgot", beforeRequest: { request in 127 | try request.content.encode(["name": "Ida"], as: .json) 128 | }) { response in 129 | XCTAssertEqual(response.status, .accepted) 130 | } 131 | 132 | XCTAssertEqual(app.mail.capturedUser?.name, "Ida") 133 | XCTAssertEqual(app.mail.capturedConfig?.expirationTimeInterval, 400) 134 | 135 | // test reset token 136 | let accessTokenPayload: UserJWTPayload = try app.jwt.signers 137 | .require(kid: UserResetKeychainConfig.jwkIdentifier) 138 | .verify(app.mail.capturedToken ?? "") 139 | XCTAssertEqual(accessTokenPayload.sub, "userID") 140 | XCTAssertEqual(accessTokenPayload.exp.value.timeIntervalSince1970, 400) 141 | } 142 | 143 | func test_forgotPassword_includesValidation() throws { 144 | try app.test(.POST, "password/forgot?fail", beforeRequest: { request in 145 | try request.content.encode(["name": "Ida"], as: .json) 146 | }) { response in 147 | XCTAssertEqual(response.status, .badRequest) 148 | let errorReason: String = try response.content.get(at: "reason") 149 | XCTAssertEqual(errorReason, "validation has failed") 150 | } 151 | } 152 | 153 | func test_resetPassword_requiresAuthentication() throws { 154 | try app.test(.POST, "password/reset") { response in 155 | XCTAssertEqual(response.status, .unauthorized) 156 | } 157 | } 158 | 159 | func test_resetPassword() throws { 160 | app.users.testUser = .test 161 | try app.test(.POST, "password/reset", beforeRequest: { request in 162 | let token = try self.app.jwt.signers 163 | .sign( 164 | UserJWTPayload(expirationDate: Date(), user: .test), 165 | kid: UserResetKeychainConfig.jwkIdentifier 166 | ) 167 | request.headers.bearerAuthorization = .init(token: token) 168 | try request.content.encode(["password": "secret2"], as: .json) 169 | }) { response in 170 | XCTAssertEqual(response.status, .ok) 171 | XCTAssertTrue(try app.password.verify( 172 | "secret2", 173 | created: app.users.testUser?.hashedPassword ?? "" 174 | )) 175 | } 176 | } 177 | 178 | func test_resetPassword_includesValidation() throws { 179 | app.users.testUser = .test 180 | try app.test(.POST, "password/reset?fail", beforeRequest: { request in 181 | let token = try self.app.jwt.signers 182 | .sign( 183 | UserJWTPayload(expirationDate: Date(), user: .test), 184 | kid: UserResetKeychainConfig.jwkIdentifier 185 | ) 186 | request.headers.bearerAuthorization = .init(token: token) 187 | try request.content.encode(["password": "secret2"], as: .json) 188 | }) { response in 189 | XCTAssertEqual(response.status, .badRequest) 190 | let errorReason: String = try response.content.get(at: "reason") 191 | XCTAssertEqual(errorReason, "validation has failed") 192 | } 193 | } 194 | 195 | func test_refreshToken_requiresAuthentication() throws { 196 | try app.test(.POST, "password/reset") { response in 197 | XCTAssertEqual(response.status, .unauthorized) 198 | } 199 | } 200 | 201 | func test_refreshToken() throws { 202 | app.users.testUser = .test 203 | try app.test(.POST, "token", beforeRequest: { request in 204 | let token = try self.app.jwt.signers 205 | .sign( 206 | UserJWTPayload(expirationDate: Date(), user: .test), 207 | kid: UserRefreshKeychainConfig.jwkIdentifier 208 | ) 209 | request.headers.bearerAuthorization = .init(token: token) 210 | }) { response in 211 | XCTAssertEqual(response.status, .ok) 212 | 213 | let token = try response.content.get(String.self, at: "refreshToken") 214 | 215 | // test refresh token 216 | let refreshTokenPayload: UserJWTPayload = try app.jwt.signers 217 | .require(kid: UserRefreshKeychainConfig.jwkIdentifier) 218 | .verify(token) 219 | XCTAssertEqual(refreshTokenPayload.sub, "userID") 220 | XCTAssertEqual(refreshTokenPayload.exp.value.timeIntervalSince1970, 600) 221 | } 222 | } 223 | 224 | func test_me_requiresAuthentication() throws { 225 | try app.test(.GET, "me") { response in 226 | XCTAssertEqual(response.status, .unauthorized) 227 | } 228 | } 229 | 230 | func test_me() throws { 231 | app.users.testUser = .test 232 | try app.test(.GET, "me", beforeRequest: { request in 233 | let token = try self.app.jwt.signers 234 | .sign( 235 | UserJWTPayload(expirationDate: Date(), user: .test), 236 | kid: UserAccessKeychainConfig.jwkIdentifier 237 | ) 238 | request.headers.bearerAuthorization = .init(token: token) 239 | }) { response in 240 | XCTAssertEqual(response.status, .ok) 241 | 242 | // test user response 243 | let userResponse = try response.content.decode(UserResponse.self) 244 | XCTAssertEqual(userResponse, .init(name: "Ida")) 245 | } 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /Tests/KeychainTests/Models/Entities/User.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | final class User: Authenticatable { 4 | let id = "userID" 5 | let name: String 6 | var hashedPassword: String 7 | 8 | init(name: String, hashedPassword: String) { 9 | self.name = name 10 | self.hashedPassword = hashedPassword 11 | } 12 | 13 | static let test = User(name: "Ida", hashedPassword: "$secret") 14 | } 15 | -------------------------------------------------------------------------------- /Tests/KeychainTests/Models/JWTPayloads/UserJWTPayload.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JWT 3 | import Keychain 4 | import Vapor 5 | 6 | struct UserJWTPayload: KeychainPayload { 7 | let exp: ExpirationClaim 8 | let sub: SubjectClaim 9 | 10 | init(expirationDate: Date, user: User) { 11 | self.exp = .init(value: expirationDate) 12 | self.sub = .init(value: user.id) 13 | } 14 | 15 | func findUser(request: Request) -> EventLoopFuture { 16 | request.eventLoop.future(request.testUser).unwrap(or: TestError.userNotFound) 17 | } 18 | 19 | func verify(using signer: JWTSigner) throws { 20 | // don't verify anything since we're not testing the JWT package itself 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/KeychainTests/Models/KeychainConfigs/UserAccessKeychainConfig.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JWT 3 | import Keychain 4 | 5 | struct UserAccessKeychainConfig: KeychainConfig, Equatable { 6 | typealias JWTPayload = UserJWTPayload 7 | 8 | static var jwkIdentifier: JWKIdentifier = "access" 9 | 10 | let expirationTimeInterval: TimeInterval = 300 11 | } 12 | -------------------------------------------------------------------------------- /Tests/KeychainTests/Models/KeychainConfigs/UserRefreshKeychainConfig.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JWT 3 | import Keychain 4 | 5 | struct UserRefreshKeychainConfig: KeychainConfig, Equatable { 6 | typealias JWTPayload = UserJWTPayload 7 | 8 | static var jwkIdentifier: JWKIdentifier = "refresh" 9 | 10 | let expirationTimeInterval: TimeInterval = 600 11 | } 12 | -------------------------------------------------------------------------------- /Tests/KeychainTests/Models/KeychainConfigs/UserResetKeychainConfig.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JWT 3 | import Keychain 4 | 5 | struct UserResetKeychainConfig: KeychainConfig, Equatable { 6 | typealias JWTPayload = UserJWTPayload 7 | 8 | static var jwkIdentifier: JWKIdentifier = "reset" 9 | 10 | let expirationTimeInterval: TimeInterval = 400 11 | } 12 | -------------------------------------------------------------------------------- /Tests/KeychainTests/Models/Requests/UserForgotPasswordRequest.swift: -------------------------------------------------------------------------------- 1 | import Keychain 2 | import Vapor 3 | 4 | struct UserForgotPasswordRequest: Decodable, ForgotPasswordRequest { 5 | typealias Config = UserResetKeychainConfig 6 | 7 | let name: String 8 | 9 | static func sendToken( 10 | _ token: String, 11 | user: User, 12 | config: Config, 13 | request: Request 14 | ) -> EventLoopFuture { 15 | request.application.mail.sendToken(token, to: user, using: config) 16 | return request.eventLoop.future() 17 | } 18 | 19 | static func validations(on request: Request) -> EventLoopFuture { 20 | var validations = Validations() 21 | if request.url.query == "fail" { 22 | validations.add("validation", result: ValidatorResults.TestFailure()) 23 | } 24 | return request.eventLoop.future(validations) 25 | } 26 | 27 | func findUser(request: Request) -> EventLoopFuture { 28 | request.eventLoop.future(request.testUser) 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /Tests/KeychainTests/Models/Requests/UserLoginRequest.swift: -------------------------------------------------------------------------------- 1 | import Keychain 2 | import Vapor 3 | 4 | struct UserLoginRequest: Decodable, LoginRequest { 5 | typealias AccessKeychainConfig = UserAccessKeychainConfig 6 | typealias RefreshKeychainConfig = UserRefreshKeychainConfig 7 | 8 | static let hashedPasswordKey: KeyPath = \.hashedPassword 9 | 10 | let password: String 11 | 12 | static func validations(on request: Request) -> EventLoopFuture { 13 | var validations = Validations() 14 | if request.url.query == "fail" { 15 | validations.add("validation", result: ValidatorResults.TestFailure()) 16 | } 17 | return request.eventLoop.future(validations) 18 | } 19 | 20 | func logIn(on request: Request) -> EventLoopFuture { 21 | request.eventLoop.future(request.testUser).unwrap(or: TestError.userNotFound) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Tests/KeychainTests/Models/Requests/UserRegisterRequest.swift: -------------------------------------------------------------------------------- 1 | import Keychain 2 | import Vapor 3 | 4 | struct UserRegisterRequest: Decodable, RegisterRequest { 5 | typealias AccessKeychainConfig = UserAccessKeychainConfig 6 | typealias RefreshKeychainConfig = UserRefreshKeychainConfig 7 | 8 | let name: String 9 | let password: String 10 | 11 | static func validations(on request: Request) -> EventLoopFuture { 12 | var validations = Validations() 13 | if request.url.query == "fail" { 14 | validations.add("validation", result: ValidatorResults.TestFailure()) 15 | } 16 | return request.eventLoop.future(validations) 17 | } 18 | 19 | func register(on request: Request) -> EventLoopFuture { 20 | request.password.async.hash(password).map { hashedPassword in 21 | User(name: self.name, hashedPassword: hashedPassword) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tests/KeychainTests/Models/Requests/UserResetPasswordRequest.swift: -------------------------------------------------------------------------------- 1 | import Keychain 2 | import Vapor 3 | 4 | struct UserResetPasswordRequest: Decodable, ResetPasswordRequest { 5 | static let hashedPasswordKey = \User.hashedPassword 6 | 7 | let password: String 8 | 9 | static func validations(on request: Request) -> EventLoopFuture { 10 | var validations = Validations() 11 | if request.url.query == "fail" { 12 | validations.add("validation", result: ValidatorResults.TestFailure()) 13 | } 14 | return request.eventLoop.future(validations) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Tests/KeychainTests/Models/Responses/RefreshTokenResponse.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct RefreshTokenResponse: Content { 4 | let refreshToken: String 5 | } 6 | -------------------------------------------------------------------------------- /Tests/KeychainTests/Models/Responses/TestAuthenticationResponse.swift: -------------------------------------------------------------------------------- 1 | struct TestAuthenticationResponse: Decodable, Equatable { 2 | let user: UserResponse 3 | let accessToken: String 4 | let refreshToken: String 5 | } 6 | -------------------------------------------------------------------------------- /Tests/KeychainTests/Models/Responses/UserResponse.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct UserResponse: Content, Equatable { 4 | let name: String 5 | } 6 | 7 | extension UserResponse { 8 | init(user: User) { 9 | self.name = user.name 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Tests/KeychainTests/Test Helpers/TestHasher.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct TestHasher: PasswordHasher { 4 | init(_: Application) {} 5 | 6 | func verify( 7 | _ password: Password, 8 | created digest: Digest 9 | ) throws -> Bool { 10 | ("$".compactMap { $0.asciiValue } + password.map { $0 }) == digest.map { $0 } 11 | } 12 | 13 | func hash(_ password: Password) throws -> [UInt8] where Password : DataProtocol { 14 | "$".compactMap { $0.asciiValue } + password.map { $0 } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Tests/KeychainTests/Test Helpers/TestJWTAlgorithm.swift: -------------------------------------------------------------------------------- 1 | import JWT 2 | 3 | struct TestJWTAlgorithm: JWTAlgorithm { 4 | let name: String 5 | func sign(_ plaintext: Plaintext) -> [UInt8] { 6 | "signed by \(name): ".compactMap { $0.asciiValue } + plaintext.map { $0 } 7 | } 8 | 9 | func verify( 10 | _ signature: Signature, 11 | signs plaintext: Plaintext 12 | ) -> Bool { 13 | signature.map { $0 } == ("signed by \(name): ".compactMap { $0.asciiValue } + plaintext) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Tests/KeychainTests/Test Helpers/TestValidatorResultFailure.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | extension ValidatorResults { 4 | struct TestFailure: ValidatorResult { 5 | var isFailure: Bool { true } 6 | var successDescription: String? { nil } 7 | var failureDescription: String? { "has failed" } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Tests/KeychainTests/Utilities/Application+mail.swift: -------------------------------------------------------------------------------- 1 | import Keychain 2 | import Vapor 3 | 4 | extension Application { 5 | final class Mail { 6 | fileprivate struct Key: StorageKey { 7 | typealias Value = Mail 8 | } 9 | 10 | var capturedToken: String? 11 | var capturedUser: User? 12 | var capturedConfig: UserResetKeychainConfig? 13 | 14 | func sendToken(_ token: String, to user: User, using config: UserResetKeychainConfig) { 15 | capturedToken = token 16 | capturedUser = user 17 | capturedConfig = config 18 | } 19 | } 20 | 21 | var mail: Mail { 22 | guard let value = storage[Mail.Key.self] else { 23 | let value = Mail() 24 | storage[Mail.Key.self] = value 25 | return value 26 | } 27 | return value 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /Tests/KeychainTests/Utilities/Application+users.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | extension Application { 4 | final class Users { 5 | fileprivate struct Key: StorageKey { 6 | typealias Value = Users 7 | } 8 | 9 | var testUser: User? 10 | } 11 | 12 | var users: Users { 13 | guard let value = storage[Users.Key.self] else { 14 | let value = Users() 15 | storage[Users.Key.self] = value 16 | return value 17 | } 18 | return value 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/KeychainTests/Utilities/Request+users.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | extension Request { 4 | var testUser: User? { 5 | get { 6 | application.users.testUser 7 | } 8 | set { 9 | application.users.testUser = newValue 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | fatalError("Please use swift test --enable-test-discovery to run the tests instead") --------------------------------------------------------------------------------