From d3d27bc10050256c5bcd6de4d1aecbdfe6ea14c2 Mon Sep 17 00:00:00 2001 From: Rita Zerrizuela Date: Fri, 9 Jun 2023 14:46:08 +0100 Subject: [PATCH] Add `renew()` to Credentials Manager [SDK-4300] (#772) --- Auth0/CredentialsManager.swift | 170 ++++++++- Auth0Tests/CredentialsManagerSpec.swift | 439 ++++++++++++++++++++++-- 2 files changed, 570 insertions(+), 39 deletions(-) diff --git a/Auth0/CredentialsManager.swift b/Auth0/CredentialsManager.swift index 6f8678ae..5a399691 100644 --- a/Auth0/CredentialsManager.swift +++ b/Auth0/CredentialsManager.swift @@ -263,7 +263,8 @@ public struct CredentialsManager { /// - headers: Additional headers to use when renewing credentials. /// - callback: Callback that receives a `Result` containing either the user's credentials or an error. /// - Requires: The scope `offline_access` to have been requested on login to get a refresh token from Auth0. If - /// the credentials are expired and there is no refresh token, the subscription will complete with a ``CredentialsManagerError/noRefreshToken`` error. + /// the credentials are expired and there is no refresh token, the callback will be called with a + /// ``CredentialsManagerError/noRefreshToken`` error. /// - Warning: Do not call `store(credentials:)` afterward. The Credentials Manager automatically persists the /// renewed credentials. Since this method is thread-safe and ``store(credentials:)`` is not, calling it anyway can /// cause concurrency issues. @@ -299,13 +300,55 @@ public struct CredentialsManager { } #endif + /// Renews credentials using the refresh token and stores them in the Keychain. **This method is thread-safe**. + /// + /// ## Usage + /// + /// ```swift + /// credentialsManager.renew { result in + /// switch result { + /// case .success(let credentials): + /// print("Renewed credentials: \(credentials)") + /// case .failure(let error): + /// print("Failed with: \(error)") + /// } + /// } + /// ``` + /// + /// If you need to specify custom parameters or headers: + /// + /// ```swift + /// credentialsManager.renew(parameters: ["key": "value"], + /// headers: ["key": "value"]) { print($0) } + /// ``` + /// + /// - Parameters: + /// - parameters: Additional parameters to use. + /// - headers: Additional headers to use. + /// - callback: Callback that receives a `Result` containing either the renewed user's credentials or an error. + /// - Requires: The scope `offline_access` to have been requested on login to get a refresh token from Auth0. If + /// there is no refresh token, the callback will be called with a ``CredentialsManagerError/noRefreshToken`` error. + /// - Warning: Do not call `store(credentials:)` afterward. The Credentials Manager automatically persists the + /// renewed credentials. Since this method is thread-safe and ``store(credentials:)`` is not, calling it anyway can + /// cause concurrency issues. + /// - Important: To ensure that no concurrent renewal requests get made, do not call this method from multiple + /// Credentials Manager instances. The Credentials Manager cannot synchronize requests across instances. + /// + /// ## See Also + /// + /// - [Refresh Tokens](https://auth0.com/docs/secure/tokens/refresh-tokens) + /// - [Authentication API Endpoint](https://auth0.com/docs/api/authentication#refresh-token) + public func renew(parameters: [String: Any] = [:], headers: [String: String] = [:], callback: @escaping (CredentialsManagerResult) -> Void) { + self.retrieveCredentials(withScope: nil, parameters: parameters, headers: headers, forceRenewal: true, callback: callback) + } + private func retrieveCredentials() -> Credentials? { guard let data = self.storage.getEntry(forKey: self.storeKey) else { return nil } return try? NSKeyedUnarchiver.unarchivedObject(ofClass: Credentials.self, from: data) } // swiftlint:disable:next function_body_length - private func retrieveCredentials(withScope scope: String?, minTTL: Int, parameters: [String: Any], headers: [String: String], callback: @escaping (CredentialsManagerResult) -> Void) { + private func retrieveCredentials(withScope scope: String?, minTTL: Int = 0, parameters: [String: Any], headers: [String: String], forceRenewal: Bool = false, callback: @escaping (CredentialsManagerResult) -> Void) { self.dispatchQueue.async { self.dispatchGroup.enter() @@ -314,12 +357,13 @@ public struct CredentialsManager { self.dispatchGroup.leave() return callback(.failure(.noCredentials)) } - guard self.hasExpired(credentials) || - self.willExpire(credentials, within: minTTL) || - self.hasScopeChanged(credentials, from: scope) else { - self.dispatchGroup.leave() - return callback(.success(credentials)) - } + guard forceRenewal || + self.hasExpired(credentials) || + self.willExpire(credentials, within: minTTL) || + self.hasScopeChanged(credentials, from: scope) else { + self.dispatchGroup.leave() + return callback(.success(credentials)) + } guard let refreshToken = credentials.refreshToken else { self.dispatchGroup.leave() return callback(.failure(.noRefreshToken)) @@ -493,7 +537,8 @@ public extension CredentialsManager { /// - headers: Additional headers to use when renewing credentials. /// - Returns: A type-erased publisher. /// - Requires: The scope `offline_access` to have been requested on login to get a refresh token from Auth0. If - /// the credentials are expired and there is no refresh token, the subscription will complete with a ``CredentialsManagerError/noRefreshToken`` error. + /// the credentials are expired and there is no refresh token, the subscription will complete with a + /// ``CredentialsManagerError/noRefreshToken`` error. /// - Warning: Do not call `store(credentials:)` afterward. The Credentials Manager automatically persists the /// renewed credentials. Since this method is thread-safe and ``store(credentials:)`` is not, calling it anyway can /// cause concurrency issues. @@ -516,6 +561,58 @@ public extension CredentialsManager { }.eraseToAnyPublisher() } + /// Renews credentials using the refresh token and stores them in the Keychain. **This method is thread-safe**. + /// + /// ## Usage + /// + /// ```swift + /// credentialsManager + /// .renew() + /// .sink(receiveCompletion: { completion in + /// if case .failure(let error) = completion { + /// print("Failed with: \(error)") + /// } + /// }, receiveValue: { credentials in + /// print("Renewed credentials: \(credentials)") + /// }) + /// .store(in: &cancellables) + /// ``` + /// + /// If you need to specify custom parameters or headers: + /// + /// ```swift + /// credentialsManager + /// .renew(parameters: ["key": "value"], + /// headers: ["key": "value"]) + /// .sink(receiveCompletion: { print($0) }, + /// receiveValue: { print($0) }) + /// .store(in: &cancellables) + /// ``` + /// + /// - Parameters: + /// - parameters: Additional parameters to use. + /// - headers: Additional headers to use. + /// - Requires: The scope `offline_access` to have been requested on login to get a refresh token from Auth0. If + /// there is no refresh token, the subscription will complete with a ``CredentialsManagerError/noRefreshToken`` + /// error. + /// - Warning: Do not call `store(credentials:)` afterward. The Credentials Manager automatically persists the + /// renewed credentials. Since this method is thread-safe and ``store(credentials:)`` is not, calling it anyway can + /// cause concurrency issues. + /// - Important: To ensure that no concurrent renewal requests get made, do not call this method from multiple + /// Credentials Manager instances. The Credentials Manager cannot synchronize requests across instances. + /// + /// ## See Also + /// + /// - [Refresh Tokens](https://auth0.com/docs/secure/tokens/refresh-tokens) + /// - [Authentication API Endpoint](https://auth0.com/docs/api/authentication#refresh-token) + func renew(parameters: [String: Any] = [:], headers: [String: String] = [:]) -> AnyPublisher { + return Deferred { + Future { callback in + return self.renew(parameters: parameters, headers: headers, callback: callback) + } + }.eraseToAnyPublisher() + } + } // MARK: - Async/Await @@ -613,7 +710,8 @@ public extension CredentialsManager { /// - Returns: The user's credentials. /// - Throws: An error of type ``CredentialsManagerError``. /// - Requires: The scope `offline_access` to have been requested on login to get a refresh token from Auth0. If - /// the credentials are expired and there is no refresh token, the subscription will complete with a ``CredentialsManagerError/noRefreshToken`` error. + /// the credentials are expired and there is no refresh token, a ``CredentialsManagerError/noRefreshToken`` error + /// will be thrown. /// - Warning: Do not call `store(credentials:)` afterward. The Credentials Manager automatically persists the /// renewed credentials. Since this method is thread-safe and ``store(credentials:)`` is not, calling it anyway can /// cause concurrency issues. @@ -647,5 +745,57 @@ public extension CredentialsManager { } #endif + #if compiler(>=5.5.2) + /// Renews credentials using the refresh token and stores them in the Keychain. **This method is thread-safe**. + /// + /// ## Usage + /// + /// ```swift + /// do { + /// let credentials = try await credentialsManager.renew() + /// print("Renewed credentials: \(credentials)") + /// } catch { + /// print("Failed with: \(error)") + /// } + /// ``` + /// + /// If you need to specify custom parameters or headers: + /// + /// ```swift + /// let credentials = try await credentialsManager.renew(parameters: ["key": "value"], + /// headers: ["key": "value"]) + /// ``` + /// + /// - Parameters: + /// - parameters: Additional parameters to use. + /// - headers: Additional headers to use. + /// - callback: Callback that receives a `Result` containing either the renewed user's credentials or an error. + /// - Requires: The scope `offline_access` to have been requested on login to get a refresh token from Auth0. If + /// there is no refresh token, a ``CredentialsManagerError/noRefreshToken`` error will be thrown. + /// - Warning: Do not call `store(credentials:)` afterward. The Credentials Manager automatically persists the + /// renewed credentials. Since this method is thread-safe and ``store(credentials:)`` is not, calling it anyway can + /// cause concurrency issues. + /// - Important: To ensure that no concurrent renewal requests get made, do not call this method from multiple + /// Credentials Manager instances. The Credentials Manager cannot synchronize requests across instances. + /// + /// ## See Also + /// + /// - [Refresh Tokens](https://auth0.com/docs/secure/tokens/refresh-tokens) + /// - [Authentication API Endpoint](https://auth0.com/docs/api/authentication#refresh-token) + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) + func renew(parameters: [String: Any] = [:], headers: [String: String] = [:]) async throws -> Credentials { + return try await withCheckedThrowingContinuation { continuation in + self.renew(parameters: parameters, headers: headers, callback: continuation.resume) + } + } + #else + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + func renew(parameters: [String: Any] = [:], headers: [String: String] = [:]) async throws -> Credentials { + return try await withCheckedThrowingContinuation { continuation in + self.renew(parameters: parameters, headers: headers, callback: continuation.resume) + } + } + #endif + } #endif diff --git a/Auth0Tests/CredentialsManagerSpec.swift b/Auth0Tests/CredentialsManagerSpec.swift index 816edd10..40d756d2 100644 --- a/Auth0Tests/CredentialsManagerSpec.swift +++ b/Auth0Tests/CredentialsManagerSpec.swift @@ -104,6 +104,22 @@ class CredentialsManagerSpec: QuickSpec { } } + describe("custom keychain") { + let storage = SimpleKeychain(service: "test_service") + + beforeEach { + credentialsManager = CredentialsManager(authentication: authentication, + storage: storage) + } + + it("custom keychain should successfully set and clear credentials") { + _ = credentialsManager.store(credentials: credentials) + expect { try storage.data(forKey: "credentials") }.toNot(beNil()) + _ = credentialsManager.clear() + expect { try storage.data(forKey: "credentials") }.to(throwError(SimpleKeychainError.itemNotFound)) + } + } + describe("clearing and revoking refresh token") { beforeEach { @@ -181,7 +197,7 @@ class CredentialsManagerSpec: QuickSpec { } } } - + describe("multi instances of credentials manager") { var secondaryCredentialsManager: CredentialsManager! @@ -390,9 +406,9 @@ class CredentialsManagerSpec: QuickSpec { describe("retrieval") { beforeEach { - stub(condition: isToken(Domain) && hasAtLeast(["refresh_token": RefreshToken])) { - _ in return authResponse(accessToken: NewAccessToken, idToken: NewIdToken, refreshToken: nil, expiresIn: ExpiresIn * 2) - }.name = "renew success" + stub(condition: isToken(Domain) && hasAtLeast(["refresh_token": RefreshToken])) { _ in + return authResponse(accessToken: NewAccessToken, idToken: NewIdToken, refreshToken: nil, expiresIn: ExpiresIn * 2) + }.name = "renewal succeeded" } afterEach { @@ -486,7 +502,7 @@ class CredentialsManagerSpec: QuickSpec { } #endif - context("renew") { + context("renewal") { it("should yield new credentials without refresh token rotation") { credentials = Credentials(accessToken: AccessToken, tokenType: TokenType, idToken: IdToken, refreshToken: RefreshToken, expiresIn: Date(timeIntervalSinceNow: -ExpiresIn)) @@ -512,25 +528,30 @@ class CredentialsManagerSpec: QuickSpec { } } } - + it("should store new credentials") { + let store = SimpleKeychain() + credentialsManager = CredentialsManager(authentication: authentication, storage: store) credentials = Credentials(accessToken: AccessToken, tokenType: TokenType, idToken: IdToken, refreshToken: RefreshToken, expiresIn: Date(timeIntervalSinceNow: -ExpiresIn)) _ = credentialsManager.store(credentials: credentials) await waitUntil(timeout: Timeout) { done in credentialsManager.credentials { result in expect(result).to(beSuccessful()) - credentialsManager.credentials { result in - expect(result).to(haveCredentials(NewAccessToken, NewIdToken, RefreshToken)) - done() - } + let storedCredentials = try? NSKeyedUnarchiver.unarchivedObject(ofClass: Credentials.self, from: store.data(forKey: "credentials")) + expect(storedCredentials?.accessToken) == NewAccessToken + expect(storedCredentials?.idToken) == NewIdToken + expect(storedCredentials?.refreshToken) == RefreshToken + done() } } } - it("should yield error on failed renew") { + it("should yield error on failed renewal") { let cause = AuthenticationError(info: ["error": "invalid_request", "error_description": "missing_params"]) let expectedError = CredentialsManagerError(code: .renewFailed, cause: cause) - stub(condition: isToken(Domain) && hasAtLeast(["refresh_token": RefreshToken])) { _ in return authFailure(code: "invalid_request", description: "missing_params") }.name = "renew failed" + stub(condition: isToken(Domain) && hasAtLeast(["refresh_token": RefreshToken])) { _ in + return authFailure(code: "invalid_request", description: "missing_params") + }.name = "renewal failed" credentials = Credentials(accessToken: AccessToken, tokenType: TokenType, idToken: IdToken, refreshToken: RefreshToken, expiresIn: Date(timeIntervalSinceNow: -ExpiresIn)) _ = credentialsManager.store(credentials: credentials) await waitUntil(timeout: Timeout) { done in @@ -570,7 +591,7 @@ class CredentialsManagerSpec: QuickSpec { } } - it("renew request should include custom parameters") { + it("renewal request should include custom parameters") { let someId = UUID().uuidString stub(condition: isToken(Domain) && hasAtLeast(["refresh_token": RefreshToken, "some_id": someId])) { _ in return authResponse(accessToken: NewAccessToken, idToken: NewIdToken, refreshToken: NewRefreshToken, expiresIn: ExpiresIn) @@ -585,7 +606,7 @@ class CredentialsManagerSpec: QuickSpec { } } - it("renew request should include custom headers") { + it("renewal request should include custom headers") { let key = "foo" let value = "bar" stub(condition: hasHeader(key, value: value)) { @@ -602,7 +623,7 @@ class CredentialsManagerSpec: QuickSpec { } } - context("forced renew") { + context("forced renewal") { beforeEach { _ = credentialsManager.store(credentials: credentials) @@ -673,7 +694,7 @@ class CredentialsManagerSpec: QuickSpec { // The dates are not mocked, so they won't match exactly let expectedError = CredentialsManagerError(code: .largeMinTTL(minTTL: minTTL, lifetime: Int(ExpiresIn - 1))) stub(condition: isToken(Domain) && hasAtLeast(["refresh_token": RefreshToken])) { - _ in return authResponse(accessToken: NewAccessToken, idToken: NewIdToken, refreshToken: nil, expiresIn: ExpiresIn) + _ in return authResponse(accessToken: NewAccessToken, idToken: NewIdToken, refreshToken: NewRefreshToken, expiresIn: ExpiresIn) } await waitUntil(timeout: Timeout) { done in credentialsManager.credentials(withScope: nil, minTTL: minTTL) { result in @@ -685,7 +706,7 @@ class CredentialsManagerSpec: QuickSpec { } - context("serial renew from same thread") { + context("serial renewal from same thread") { it("should yield the stored credentials after the previous renewal operation succeeded") { credentials = Credentials(accessToken: AccessToken, tokenType: TokenType, idToken: IdToken, refreshToken: RefreshToken, expiresIn: Date(timeIntervalSinceNow: -ExpiresIn)) @@ -724,7 +745,7 @@ class CredentialsManagerSpec: QuickSpec { } - context("serial renew from different threads") { + context("serial renewal from different threads") { it("should yield the stored credentials after the previous renewal operation succeeded") { credentials = Credentials(accessToken: AccessToken, tokenType: TokenType, idToken: IdToken, refreshToken: RefreshToken, expiresIn: Date(timeIntervalSinceNow: -ExpiresIn)) @@ -770,24 +791,205 @@ class CredentialsManagerSpec: QuickSpec { } } } + } + + } + describe("renew") { + + beforeEach { + stub(condition: isToken(Domain) && hasAtLeast(["refresh_token": RefreshToken])) { _ in + return authResponse(accessToken: NewAccessToken, idToken: NewIdToken, refreshToken: NewRefreshToken, expiresIn: ExpiresIn * 2) + }.name = "renewal succeeded" } - context("custom keychain") { - let storage = SimpleKeychain(service: "test_service") + afterEach { + _ = credentialsManager.clear() + } - beforeEach { - credentialsManager = CredentialsManager(authentication: authentication, - storage: storage) + it("should error when no credentials stored") { + _ = credentialsManager.clear() + + await waitUntil(timeout: Timeout) { done in + credentialsManager.renew { result in + expect(result).to(haveCredentialsManagerError(CredentialsManagerError(code: .noCredentials))) + done() + } } + } - it("custom keychain should successfully set and clear credentials") { + it("should error when no refresh token") { + credentials = Credentials(accessToken: AccessToken, tokenType: TokenType, idToken: IdToken, refreshToken: nil) + _ = credentialsManager.store(credentials: credentials) + + await waitUntil(timeout: Timeout) { done in + credentialsManager.renew { result in + expect(result).to(haveCredentialsManagerError(CredentialsManagerError(code: .noRefreshToken))) + done() + } + } + } + + it("should yield new credentials without refresh token rotation") { + stub(condition: isToken(Domain) && hasAtLeast(["refresh_token": RefreshToken])) { _ in + return authResponse(accessToken: NewAccessToken, idToken: NewIdToken, refreshToken: nil, expiresIn: ExpiresIn * 2) + }.name = "renewal succeeded" + _ = credentialsManager.store(credentials: credentials) + await waitUntil(timeout: Timeout) { done in + credentialsManager.renew { result in + expect(result).to(haveCredentials(NewAccessToken, NewIdToken, RefreshToken)) + done() + } + } + } + + it("should yield new credentials with refresh token rotation") { + _ = credentialsManager.store(credentials: credentials) + await waitUntil(timeout: Timeout) { done in + credentialsManager.renew { result in + expect(result).to(haveCredentials(NewAccessToken, NewIdToken, NewRefreshToken)) + done() + } + } + } + + it("should store new credentials") { + let store = SimpleKeychain() + credentialsManager = CredentialsManager(authentication: authentication, storage: store) + _ = credentialsManager.store(credentials: credentials) + await waitUntil(timeout: Timeout) { done in + credentialsManager.renew { result in + expect(result).to(beSuccessful()) + let storedCredentials = try? NSKeyedUnarchiver.unarchivedObject(ofClass: Credentials.self, from: store.data(forKey: "credentials")) + expect(storedCredentials?.accessToken) == NewAccessToken + expect(storedCredentials?.idToken) == NewIdToken + expect(storedCredentials?.refreshToken) == NewRefreshToken + done() + } + } + } + + it("should yield error on failed renewal") { + let cause = AuthenticationError(info: ["error": "invalid_request", "error_description": "missing_params"]) + let expectedError = CredentialsManagerError(code: .renewFailed, cause: cause) + stub(condition: isToken(Domain) && hasAtLeast(["refresh_token": RefreshToken])) { _ in + return authFailure(code: "invalid_request", description: "missing_params") + }.name = "renewal failed" + _ = credentialsManager.store(credentials: credentials) + await waitUntil(timeout: Timeout) { done in + credentialsManager.renew { result in + expect(result).to(haveCredentialsManagerError(expectedError)) + done() + } + } + } + + it("should yield error on failed store") { + class MockStore: CredentialsStorage { + func getEntry(forKey: String) -> Data? { + let credentials = Credentials(accessToken: AccessToken, idToken: IdToken, refreshToken: RefreshToken) + let data = try? NSKeyedArchiver.archivedData(withRootObject: credentials, + requiringSecureCoding: true) + return data + } + func setEntry(_ data: Data, forKey: String) -> Bool { + return false + } + func deleteEntry(forKey: String) -> Bool { + return true + } + } + + credentialsManager = CredentialsManager(authentication: authentication, storage: MockStore()) + await waitUntil(timeout: Timeout) { done in + credentialsManager.renew { result in + expect(result).to(haveCredentialsManagerError(.storeFailed)) + done() + } + } + } + + it("renewal request should include custom parameters") { + let someId = UUID().uuidString + stub(condition: isToken(Domain) && hasAtLeast(["refresh_token": RefreshToken, "some_id": someId])) { + _ in return authResponse(accessToken: NewAccessToken, idToken: NewIdToken, refreshToken: NewRefreshToken, expiresIn: ExpiresIn) + } + _ = credentialsManager.store(credentials: credentials) + await waitUntil(timeout: Timeout) { done in + credentialsManager.renew(parameters: ["some_id": someId]) { result in + expect(result).to(haveCredentials(NewAccessToken, NewIdToken, NewRefreshToken)) + done() + } + } + } + + it("renewal request should include custom headers") { + let key = "foo" + let value = "bar" + stub(condition: hasHeader(key, value: value)) { + _ in return authResponse(accessToken: NewAccessToken, idToken: NewIdToken, refreshToken: NewRefreshToken, expiresIn: ExpiresIn) + } + _ = credentialsManager.store(credentials: credentials) + await waitUntil(timeout: Timeout) { done in + credentialsManager.renew(headers: [key: value]) { result in + expect(result).to(beSuccessful()) + done() + } + } + } + + context("concurrency") { + let newAccessToken1 = "new-access-token-1" + let newIDToken1 = "new-id-token-1" + let newRefreshToken1 = "new-refresh-token-1" + let newAccessToken2 = "new-access-token-2" + let newIDToken2 = "new-id-token-2" + let newRefreshToken2 = "new-refresh-token-2" + + it("should renew the credentials serially from the same thread") { _ = credentialsManager.store(credentials: credentials) - expect { try storage.data(forKey: "credentials") }.toNot(beNil()) - _ = credentialsManager.clear() - expect { try storage.data(forKey: "credentials") }.to(throwError(SimpleKeychainError.itemNotFound)) + stub(condition: isToken(Domain) && hasAtLeast(["refresh_token": RefreshToken, "request": "first"])) { request in + return authResponse(accessToken: newAccessToken1, idToken: newIDToken1, refreshToken: newRefreshToken1, expiresIn: ExpiresIn) + } + await waitUntil(timeout: Timeout) { done in + credentialsManager.renew(parameters: ["request": "first"]) { result in + expect(result).to(haveCredentials(newAccessToken1, newIDToken1, newRefreshToken1)) + stub(condition: isToken(Domain) && hasAtLeast(["refresh_token": newRefreshToken1, "request": "second"])) { request in + return authResponse(accessToken: newAccessToken2, idToken: newIDToken2, refreshToken: newRefreshToken2, expiresIn: ExpiresIn) + } + } + credentialsManager.renew(parameters: ["request": "second"]) { result in + expect(result).to(haveCredentials(newAccessToken2, newIDToken2, newRefreshToken2)) + done() + } + } + } + + it("should renew the credentials serially from different threads") { + _ = credentialsManager.store(credentials: credentials) + stub(condition: isToken(Domain) && hasAtLeast(["refresh_token": RefreshToken, "request": "first"])) { request in + return authResponse(accessToken: newAccessToken1, idToken: newIDToken1, refreshToken: newRefreshToken1, expiresIn: ExpiresIn) + } + stub(condition: isToken(Domain) && hasAtLeast(["refresh_token": newRefreshToken1, "request": "second"])) { request in + return authResponse(accessToken: newAccessToken2, idToken: newIDToken2, refreshToken: newRefreshToken2, expiresIn: ExpiresIn) + } + await waitUntil(timeout: Timeout) { [credentialsManager] done in + DispatchQueue.global(qos: .utility).sync { + credentialsManager?.renew(parameters: ["request": "first"]) { result in + expect(result).to(haveCredentials(newAccessToken1, newIDToken1, newRefreshToken1)) + } + } + DispatchQueue.global(qos: .background).sync { + credentialsManager?.renew(parameters: ["request": "second"]) { result in + expect(result).to(haveCredentials(newAccessToken2, newIDToken2, newRefreshToken2)) + done() + } + } + } } + } + } if #available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) { @@ -835,7 +1037,7 @@ class CredentialsManagerSpec: QuickSpec { stub(condition: isToken(Domain) && hasAtLeast(["refresh_token": RefreshToken, key: value]) && hasHeader(key, value: value)) { _ in return authResponse(accessToken: NewAccessToken, idToken: NewIdToken, refreshToken: NewRefreshToken, expiresIn: ExpiresIn) } - credentials = Credentials(accessToken: AccessToken, tokenType: TokenType, idToken: IdToken, refreshToken: RefreshToken, expiresIn: Date(timeIntervalSinceNow: -ExpiresIn), scope: "openid profile") + credentials = Credentials(accessToken: AccessToken, tokenType: TokenType, idToken: IdToken, refreshToken: RefreshToken, expiresIn: Date(timeIntervalSinceNow: -ExpiresIn)) _ = credentialsManager.store(credentials: credentials) await waitUntil(timeout: Timeout) { done in credentialsManager @@ -866,6 +1068,79 @@ class CredentialsManagerSpec: QuickSpec { } + context("renew") { + + beforeEach { + _ = credentialsManager.store(credentials: credentials) + } + + it("should emit only one value") { + stub(condition: isToken(Domain) && hasAtLeast(["refresh_token": RefreshToken])) { _ in + return authResponse(accessToken: NewAccessToken, idToken: NewIdToken, refreshToken: NewRefreshToken, expiresIn: ExpiresIn * 2) + }.name = "renewal succeeded" + await waitUntil(timeout: Timeout) { done in + credentialsManager + .renew() + .assertNoFailure() + .count() + .sink(receiveValue: { count in + expect(count) == 1 + done() + }) + .store(in: &cancellables) + } + } + + it("should complete using the default parameter values") { + stub(condition: isToken(Domain) && hasAtLeast(["refresh_token": RefreshToken])) { _ in + return authResponse(accessToken: NewAccessToken, idToken: NewIdToken, refreshToken: NewRefreshToken, expiresIn: ExpiresIn * 2) + }.name = "renewal succeeded" + await waitUntil(timeout: Timeout) { done in + credentialsManager + .renew() + .sink(receiveCompletion: { completion in + guard case .finished = completion else { return } + done() + }, receiveValue: { _ in }) + .store(in: &cancellables) + } + } + + it("should complete using custom parameter values") { + let key = "foo" + let value = "bar" + stub(condition: isToken(Domain) && hasAtLeast(["refresh_token": RefreshToken, key: value]) && hasHeader(key, value: value)) { _ in + return authResponse(accessToken: NewAccessToken, idToken: NewIdToken, refreshToken: NewRefreshToken, expiresIn: ExpiresIn) + } + await waitUntil(timeout: Timeout) { done in + credentialsManager + .renew(parameters: [key: value], headers: [key: value]) + .sink(receiveCompletion: { completion in + guard case .finished = completion else { return } + done() + }, receiveValue: { _ in }) + .store(in: &cancellables) + } + } + + it("should complete with an error") { + stub(condition: isToken(Domain) && hasAtLeast(["refresh_token": RefreshToken])) { _ in + return authFailure(code: "invalid_request", description: "missing_params") + }.name = "renewal failed" + await waitUntil(timeout: Timeout) { done in + credentialsManager + .renew() + .ignoreOutput() + .sink(receiveCompletion: { completion in + guard case .failure = completion else { return } + done() + }, receiveValue: { _ in }) + .store(in: &cancellables) + } + } + + } + context("revoke") { it("should emit only one value") { @@ -982,7 +1257,7 @@ class CredentialsManagerSpec: QuickSpec { stub(condition: isToken(Domain) && hasAtLeast(["refresh_token": RefreshToken, key: value]) && hasHeader(key, value: "bar")) { _ in return authResponse(accessToken: NewAccessToken, idToken: NewIdToken, refreshToken: NewRefreshToken, expiresIn: ExpiresIn) } - credentials = Credentials(accessToken: AccessToken, tokenType: TokenType, idToken: IdToken, refreshToken: RefreshToken, expiresIn: Date(timeIntervalSinceNow: -ExpiresIn), scope: "openid profile") + credentials = Credentials(accessToken: AccessToken, tokenType: TokenType, idToken: IdToken, refreshToken: RefreshToken, expiresIn: Date(timeIntervalSinceNow: -ExpiresIn)) _ = credentialsManager.store(credentials: credentials) await waitUntil(timeout: Timeout) { done in #if compiler(>=5.5.2) @@ -1042,6 +1317,112 @@ class CredentialsManagerSpec: QuickSpec { } + context("renew") { + + beforeEach { + _ = credentialsManager.store(credentials: credentials) + } + + it("should renew the credentials using the default parameter values") { + let credentialsManager = credentialsManager! + stub(condition: isToken(Domain) && hasAtLeast(["refresh_token": RefreshToken])) { _ in + return authResponse(accessToken: NewAccessToken, idToken: NewIdToken, refreshToken: NewRefreshToken, expiresIn: ExpiresIn * 2) + }.name = "renewal succeeded" + await waitUntil(timeout: Timeout) { done in + #if compiler(>=5.5.2) + if #available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) { + Task.init { + let newCredentials = try await credentialsManager.renew() + expect(newCredentials.accessToken) == NewAccessToken + expect(newCredentials.idToken) == NewIdToken + expect(newCredentials.refreshToken) == NewRefreshToken + done() + } + } + #else + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) { + Task.init { + let newCredentials = try await credentialsManager.renew() + expect(newCredentials.accessToken) == NewAccessToken + expect(newCredentials.idToken) == NewIdToken + expect(newCredentials.refreshToken) == NewRefreshToken + done() + } + } else { + done() + } + #endif + } + } + + it("should renew the credentials using custom parameter values") { + let key = "foo" + let value = "bar" + let credentialsManager = credentialsManager! + stub(condition: isToken(Domain) && hasAtLeast(["refresh_token": RefreshToken, key: value]) && hasHeader(key, value: "bar")) { _ in + return authResponse(accessToken: NewAccessToken, idToken: NewIdToken, refreshToken: NewRefreshToken, expiresIn: ExpiresIn) + } + await waitUntil(timeout: Timeout) { done in + #if compiler(>=5.5.2) + if #available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) { + Task.init { + let newCredentials = try await credentialsManager.renew(parameters: [key: value], headers: [key: value]) + expect(newCredentials.accessToken) == NewAccessToken + expect(newCredentials.idToken) == NewIdToken + expect(newCredentials.refreshToken) == NewRefreshToken + done() + } + } + #else + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) { + Task.init { + let newCredentials = try await credentialsManager.renew(parameters: [key: value], headers: [key: value]) + expect(newCredentials.accessToken) == NewAccessToken + expect(newCredentials.idToken) == NewIdToken + expect(newCredentials.refreshToken) == NewRefreshToken + done() + } + } else { + done() + } + #endif + } + } + + it("should throw an error") { + let credentialsManager = credentialsManager! + stub(condition: isToken(Domain) && hasAtLeast(["refresh_token": RefreshToken])) { _ in + return authFailure(code: "invalid_request", description: "missing_params") + }.name = "renewal failed" + await waitUntil(timeout: Timeout) { done in + #if compiler(>=5.5.2) + if #available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) { + Task.init { + do { + _ = try await credentialsManager.renew() + } catch { + done() + } + } + } + #else + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) { + Task.init { + do { + _ = try await credentialsManager.renew() + } catch { + done() + } + } + } else { + done() + } + #endif + } + } + + } + context("revoke") { it("should revoke using the default parameter values") {