diff --git a/AuthenticationExample/AuthenticationExample/AuthenticationApp.swift b/AuthenticationExample/AuthenticationExample/AuthenticationApp.swift index 4172b8abd..8d0824cdc 100644 --- a/AuthenticationExample/AuthenticationExample/AuthenticationApp.swift +++ b/AuthenticationExample/AuthenticationExample/AuthenticationApp.swift @@ -26,9 +26,9 @@ struct AuthenticationApp: App { // If you want to use OAuth, uncomment this code: //oAuthUserConfigurations: [.arcgisDotCom] ) - // Set the ArcGIS and Network challenge handlers to be the authenticator we just created. - ArcGISEnvironment.authenticationManager.arcGISAuthenticationChallengeHandler = authenticator - ArcGISEnvironment.authenticationManager.networkAuthenticationChallengeHandler = authenticator + // Sets authenticator as ArcGIS and Network challenge handlers to handle authentication + // challenges. + ArcGISEnvironment.authenticationManager.handleChallenges(using: authenticator) } var body: some SwiftUI.Scene { @@ -50,12 +50,12 @@ struct AuthenticationApp: App { .environmentObject(authenticator) .task { isSettingUp = true - // Here we make the authenticator persistent, which means that it will synchronize - // with they keychain for storing credentials. + // Here we setup credential stores to be persistent, which means that it will + // synchronize with the keychain for storing credentials. // It also means that a user can sign in without having to be prompted for // credentials. Once credentials are cleared from the stores ("sign-out"), // then the user will need to be prompted once again. - try? await authenticator.setupPersistentCredentialStorage(access: .whenUnlockedThisDeviceOnly) + try? await ArcGISEnvironment.authenticationManager.setupPersistentCredentialStorage(access: .whenUnlockedThisDeviceOnly) isSettingUp = false } } diff --git a/AuthenticationExample/AuthenticationExample/ProfileView.swift b/AuthenticationExample/AuthenticationExample/ProfileView.swift index 1dbb55985..1fb1fed6f 100644 --- a/AuthenticationExample/AuthenticationExample/ProfileView.swift +++ b/AuthenticationExample/AuthenticationExample/ProfileView.swift @@ -61,7 +61,8 @@ struct ProfileView: View { func signOut() { isSigningOut = true Task { - await authenticator.clearCredentialStores() + await ArcGISEnvironment.authenticationManager.revokeOAuthTokens() + await ArcGISEnvironment.authenticationManager.clearCredentialStores() isSigningOut = false signOutAction() } diff --git a/Documentation/Authenticator/README.md b/Documentation/Authenticator/README.md index a14081918..2368cbf81 100644 --- a/Documentation/Authenticator/README.md +++ b/Documentation/Authenticator/README.md @@ -1,12 +1,12 @@ # Authenticator -The `Authenticator` is a configurable object that handles authentication challenges. It will display a user interface when network and ArcGIS authentication challenges occur. +The `Authenticator` is a configurable object that handles authentication challenges. It will display a user interface when network and ArcGIS authentication challenges occur. ![image](https://user-images.githubusercontent.com/3998072/203615041-c887d5e3-bb64-469a-a76b-126059329e92.png) ## Features -The `Authenticator` has a view modifier that will display a prompt when the `Authenticator` is asked to handle an authentication challenge. This will handle many different types of authentication, for example: +The `Authenticator` has a view modifier that will display a prompt when the `Authenticator` is asked to handle an authentication challenge. This will handle many different types of authentication, for example: - ArcGIS authentication (token and OAuth) - Integrated Windows Authentication (IWA) - Client Certificate (PKI) @@ -23,7 +23,7 @@ The `Authenticator` can be configured to support securely persisting credentials @ViewBuilder func authenticator(_ authenticator: Authenticator) -> some View ``` -To securely store credentials in the keychain, use the following instance method on `Authenticator`: +To securely store credentials in the keychain, use the following extension method of `AuthenticationManager`: ```swift /// Sets up new credential stores that will be persisted to the keychain. @@ -39,6 +39,18 @@ To securely store credentials in the keychain, use the following instance method ) async throws ``` +During sign-out, use the following extension methods of `AuthenticationManager`: + +```swift + /// Revokes tokens of OAuth user credentials. + func revokeOAuthTokens() async + + /// Clears all ArcGIS and network credentials from the respective stores. + /// Note: This sets up new `URLSessions` so that removed network credentials are respected + /// right away. + func clearCredentialStores() async +``` + ## Behavior: The Authenticator view modifier will display an alert prompting the user for credentials. If credentials were persisted to the keychain, the Authenticator will use those instead of requiring the user to reenter credentials. @@ -56,8 +68,9 @@ init() { // If you want to use OAuth, uncomment this code: //oAuthConfigurations: [.arcgisDotCom] ) - // Set the challenge handler to be the authenticator we just created. - ArcGISEnvironment.authenticationChallengeHandler = authenticator + // Sets authenticator as ArcGIS and Network challenge handlers to handle authentication + // challenges. + ArcGISEnvironment.authenticationManager.handleChallenges(using: authenticator) } var body: some SwiftUI.Scene { @@ -65,12 +78,12 @@ var body: some SwiftUI.Scene { HomeView() .authenticator(authenticator) .task { - // Here we make the authenticator persistent, which means that it will synchronize - // with the keychain for storing credentials. + // Here we setup credential stores to be persistent, which means that it will + // synchronize with the keychain for storing credentials. // It also means that a user can sign in without having to be prompted for // credentials. Once credentials are cleared from the stores ("sign-out"), // then the user will need to be prompted once again. - try? await authenticator.setupPersistentCredentialStorage(access: .whenUnlockedThisDeviceOnly) + try? await ArcGISEnvironment.authenticationManager.setupPersistentCredentialStorage(access: .whenUnlockedThisDeviceOnly) } } } diff --git a/Sources/ArcGISToolkit/Components/Authentication/AuthenticationManager.swift b/Sources/ArcGISToolkit/Components/Authentication/AuthenticationManager.swift new file mode 100644 index 000000000..05a72dd07 --- /dev/null +++ b/Sources/ArcGISToolkit/Components/Authentication/AuthenticationManager.swift @@ -0,0 +1,89 @@ +// Copyright 2023 Esri. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import ArcGIS + +public extension AuthenticationManager { + /// Sets up authenticator as ArcGIS and Network challenge handlers to handle authentication + /// challenges. + /// - Parameter authenticator: The authenticator to be used for handling challenges. + func handleChallenges(using authenticator: Authenticator) { + arcGISAuthenticationChallengeHandler = authenticator + networkAuthenticationChallengeHandler = authenticator + } + + /// Sets up new credential stores that will be persisted to the keychain. + /// - Remark: The credentials will be stored in the default access group of the keychain. + /// You can find more information about what the default group would be here: + /// https://developer.apple.com/documentation/security/keychain_services/keychain_items/sharing_access_to_keychain_items_among_a_collection_of_apps + /// - Parameters: + /// - access: When the credentials stored in the keychain can be accessed. + /// - synchronizesWithiCloud: A Boolean value indicating whether the credentials are synchronized with iCloud. + func setupPersistentCredentialStorage( + access: ArcGIS.KeychainAccess, + synchronizesWithiCloud: Bool = false + ) async throws { + let previousArcGISCredentialStore = arcGISCredentialStore + + // Set a persistent ArcGIS credential store on the ArcGIS environment. + arcGISCredentialStore = try await .makePersistent( + access: access, + synchronizesWithiCloud: synchronizesWithiCloud + ) + + do { + // Set a persistent network credential store on the ArcGIS environment. + await setNetworkCredentialStore( + try await .makePersistent(access: access, synchronizesWithiCloud: synchronizesWithiCloud) + ) + } catch { + // If making the shared network credential store persistent fails, + // then restore the ArcGIS credential store. + arcGISCredentialStore = previousArcGISCredentialStore + throw error + } + } + + /// Clears all ArcGIS and network credentials from the respective stores. + /// Note: This sets up new `URLSessions` so that removed network credentials are respected + /// right away. + func clearCredentialStores() async { + // Clear ArcGIS Credentials. + arcGISCredentialStore.removeAll() + + // Clear network credentials. + await networkCredentialStore.removeAll() + } + + /// Revokes tokens of OAuth user credentials. + /// - Returns: `true` if successfully revokes tokens for all `OAuthUserCredential`, otherwise + /// `false`. + @discardableResult + func revokeOAuthTokens() async -> Bool { + let oAuthUserCredentials = arcGISCredentialStore.credentials.compactMap { $0 as? OAuthUserCredential } + return await withTaskGroup(of: Bool.self, returning: Bool.self) { group in + for credential in oAuthUserCredentials { + group.addTask { + do { + try await credential.revokeToken() + return true + } catch { + return false + } + } + } + + return await group.allSatisfy { $0 == true } + } + } +} diff --git a/Sources/ArcGISToolkit/Components/Authentication/Authenticator.swift b/Sources/ArcGISToolkit/Components/Authentication/Authenticator.swift index 7d99eef56..bd909618d 100644 --- a/Sources/ArcGISToolkit/Components/Authentication/Authenticator.swift +++ b/Sources/ArcGISToolkit/Components/Authentication/Authenticator.swift @@ -37,49 +37,6 @@ public final class Authenticator: ObservableObject { self.oAuthUserConfigurations = oAuthUserConfigurations } - /// Sets up new credential stores that will be persisted to the keychain. - /// - Remark: The credentials will be stored in the default access group of the keychain. - /// You can find more information about what the default group would be here: - /// https://developer.apple.com/documentation/security/keychain_services/keychain_items/sharing_access_to_keychain_items_among_a_collection_of_apps - /// - Parameters: - /// - access: When the credentials stored in the keychain can be accessed. - /// - synchronizesWithiCloud: A Boolean value indicating whether the credentials are synchronized with iCloud. - public func setupPersistentCredentialStorage( - access: ArcGIS.KeychainAccess, - synchronizesWithiCloud: Bool = false - ) async throws { - let previousArcGISCredentialStore = ArcGISEnvironment.authenticationManager.arcGISCredentialStore - - // Set a persistent ArcGIS credential store on the ArcGIS environment. - ArcGISEnvironment.authenticationManager.arcGISCredentialStore = try await .makePersistent( - access: access, - synchronizesWithiCloud: synchronizesWithiCloud - ) - - do { - // Set a persistent network credential store on the ArcGIS environment. - await ArcGISEnvironment.authenticationManager.setNetworkCredentialStore( - try await .makePersistent(access: access, synchronizesWithiCloud: synchronizesWithiCloud) - ) - } catch { - // If making the shared network credential store persistent fails, - // then restore the ArcGIS credential store. - ArcGISEnvironment.authenticationManager.arcGISCredentialStore = previousArcGISCredentialStore - throw error - } - } - - /// Clears all ArcGIS and network credentials from the respective stores. - /// Note: This sets up new `URLSessions` so that removed network credentials are respected - /// right away. - public func clearCredentialStores() async { - // Clear ArcGIS Credentials. - ArcGISEnvironment.authenticationManager.arcGISCredentialStore.removeAll() - - // Clear network credentials. - await ArcGISEnvironment.authenticationManager.networkCredentialStore.removeAll() - } - /// The current challenge. /// This property is not set for OAuth challenges. @Published var currentChallenge: ChallengeContinuation? diff --git a/Tests/ArcGISToolkitTests/AuthenticatorTests.swift b/Tests/ArcGISToolkitTests/AuthenticatorTests.swift index 6c8e201ee..291a6186c 100644 --- a/Tests/ArcGISToolkitTests/AuthenticatorTests.swift +++ b/Tests/ArcGISToolkitTests/AuthenticatorTests.swift @@ -36,9 +36,8 @@ import Combine } // This tests that calling setupPersistentCredentialStorage tries to sync with the keychain. - let authenticator = Authenticator() do { - try await authenticator.setupPersistentCredentialStorage(access: .whenUnlocked) + try await ArcGISEnvironment.authenticationManager.setupPersistentCredentialStorage(access: .whenUnlocked) XCTFail("Expected an error to be thrown as unit tests should not have access to the keychain") } catch {} } @@ -55,12 +54,10 @@ import Combine ) ) - let authenticator = Authenticator() - var arcGISCreds = ArcGISEnvironment.authenticationManager.arcGISCredentialStore.credentials XCTAssertEqual(arcGISCreds.count, 1) - await authenticator.clearCredentialStores() + await ArcGISEnvironment.authenticationManager.clearCredentialStores() arcGISCreds = ArcGISEnvironment.authenticationManager.arcGISCredentialStore.credentials XCTAssertTrue(arcGISCreds.isEmpty) diff --git a/Tests/ArcGISToolkitTests/Test Support/Extensions/XCTest/XCTestCase+ArcGISURLSession.swift b/Tests/ArcGISToolkitTests/Test Support/Extensions/XCTest/XCTestCase+ArcGISURLSession.swift index 5acd4eebc..286b1fa16 100644 --- a/Tests/ArcGISToolkitTests/Test Support/Extensions/XCTest/XCTestCase+ArcGISURLSession.swift +++ b/Tests/ArcGISToolkitTests/Test Support/Extensions/XCTest/XCTestCase+ArcGISURLSession.swift @@ -20,9 +20,19 @@ import XCTest import ArcGIS extension XCTestCase { - func setChallengeHandler(_ challengeHandler: ArcGISAuthenticationChallengeHandler) { + /// Sets up an ArcGIS challenge handler on the `ArcGISURLSession` and registers a tear-down block to + /// reset it to previous handler. + func setArcGISChallengeHandler(_ challengeHandler: ArcGISAuthenticationChallengeHandler) { let previous = ArcGISEnvironment.authenticationManager.arcGISAuthenticationChallengeHandler ArcGISEnvironment.authenticationManager.arcGISAuthenticationChallengeHandler = challengeHandler addTeardownBlock { ArcGISEnvironment.authenticationManager.arcGISAuthenticationChallengeHandler = previous } } + + /// Sets up a network challenge handler on the `ArcGISURLSession` and registers a tear-down block to + /// reset it to previous handler. + func setNetworkChallengeHandler(_ challengeHandler: NetworkAuthenticationChallengeHandler) { + let previous = ArcGISEnvironment.authenticationManager.networkAuthenticationChallengeHandler + ArcGISEnvironment.authenticationManager.networkAuthenticationChallengeHandler = challengeHandler + addTeardownBlock { ArcGISEnvironment.authenticationManager.networkAuthenticationChallengeHandler = previous } + } } diff --git a/Tests/ArcGISToolkitTests/Test Support/Utility/ArcGISChallengeHandler.swift b/Tests/ArcGISToolkitTests/Test Support/Utility/ArcGISChallengeHandler.swift new file mode 100644 index 000000000..34d5087fd --- /dev/null +++ b/Tests/ArcGISToolkitTests/Test Support/Utility/ArcGISChallengeHandler.swift @@ -0,0 +1,47 @@ +// +// COPYRIGHT 1995-2022 ESRI +// +// TRADE SECRETS: ESRI PROPRIETARY AND CONFIDENTIAL +// Unpublished material - all rights reserved under the +// Copyright Laws of the United States and applicable international +// laws, treaties, and conventions. +// +// For additional information, contact: +// Environmental Systems Research Institute, Inc. +// Attn: Contracts and Legal Services Department +// 380 New York Street +// Redlands, California, 92373 +// USA +// +// email: contracts@esri.com +// + +import Foundation +import ArcGIS + +/// An `ArcGISChallengeHandler` that can handle challenges using ArcGIS credential. +final class ArcGISChallengeHandler: ArcGISAuthenticationChallengeHandler { + /// The arcgis credential used when an ArcGIS challenge is received. + let credentialProvider: ((ArcGISAuthenticationChallenge) async throws -> ArcGISCredential?) + + /// The ArcGIS authentication challenges. + private(set) var challenges: [ArcGISAuthenticationChallenge] = [] + + init( + credentialProvider: @escaping ((ArcGISAuthenticationChallenge) async throws -> ArcGISCredential?) + ) { + self.credentialProvider = credentialProvider + } + + func handleArcGISAuthenticationChallenge( + _ challenge: ArcGISAuthenticationChallenge + ) async throws -> ArcGISAuthenticationChallenge.Disposition { + challenges.append(challenge) + + if let arcgisCredential = try await credentialProvider(challenge) { + return .continueWithCredential(arcgisCredential) + } else { + return .continueWithoutCredential + } + } +} diff --git a/Tests/ArcGISToolkitTests/Test Support/Utility/ChallengeHandler.swift b/Tests/ArcGISToolkitTests/Test Support/Utility/ChallengeHandler.swift deleted file mode 100644 index cfd9e8b09..000000000 --- a/Tests/ArcGISToolkitTests/Test Support/Utility/ChallengeHandler.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// COPYRIGHT 1995-2022 ESRI -// -// TRADE SECRETS: ESRI PROPRIETARY AND CONFIDENTIAL -// Unpublished material - all rights reserved under the -// Copyright Laws of the United States and applicable international -// laws, treaties, and conventions. -// -// For additional information, contact: -// Environmental Systems Research Institute, Inc. -// Attn: Contracts and Legal Services Department -// 380 New York Street -// Redlands, California, 92373 -// USA -// -// email: contracts@esri.com -// - -import Foundation -import ArcGIS - -/// A `ChallengeHandler` that that can handle trusting hosts with a self-signed certificate, the URL credential, -/// and the token credential. -class ChallengeHandler: ArcGISAuthenticationChallengeHandler { - /// The hosts that can be trusted if they have certificate trust issues. - let trustedHosts: Set - - /// The url credential used when a challenge is thrown. - let networkCredentialProvider: ((NetworkAuthenticationChallenge) async -> NetworkCredential?)? - - /// The arcgis credential used when an ArcGIS challenge is received. - let arcgisCredentialProvider: ((ArcGISAuthenticationChallenge) async throws -> ArcGISCredential?)? - - /// The network authentication challenges. - private(set) var networkChallenges: [NetworkAuthenticationChallenge] = [] - - /// The ArcGIS authentication challenges. - private(set) var arcGISChallenges: [ArcGISAuthenticationChallenge] = [] - - init( - trustedHosts: Set = [], - networkCredentialProvider: ((NetworkAuthenticationChallenge) async -> NetworkCredential?)? = nil, - arcgisCredentialProvider: ((ArcGISAuthenticationChallenge) async throws -> ArcGISCredential?)? = nil - ) { - self.trustedHosts = trustedHosts - self.networkCredentialProvider = networkCredentialProvider - self.arcgisCredentialProvider = arcgisCredentialProvider - } - - convenience init( - trustedHosts: Set, - networkCredential: NetworkCredential - ) { - self.init(trustedHosts: trustedHosts, networkCredentialProvider: { _ in networkCredential }) - } - - func handleNetworkAuthenticationChallenge( - _ challenge: NetworkAuthenticationChallenge - ) async -> NetworkAuthenticationChallenge.Disposition { - // Record challenge only if it is not a server trust. - if challenge.kind != .serverTrust { - networkChallenges.append(challenge) - } - - if challenge.kind == .serverTrust { - if trustedHosts.contains(challenge.host) { - // This will cause a self-signed certificate to be trusted. - return .continueWithCredential(.serverTrust) - } else { - return .continueWithoutCredential - } - } else if let networkCredentialProvider = networkCredentialProvider, - let networkCredential = await networkCredentialProvider(challenge) { - return .continueWithCredential(networkCredential) - } else { - return .cancel - } - } - - func handleArcGISAuthenticationChallenge( - _ challenge: ArcGISAuthenticationChallenge - ) async throws -> ArcGISAuthenticationChallenge.Disposition { - arcGISChallenges.append(challenge) - - if let arcgisCredentialProvider = arcgisCredentialProvider, - let arcgisCredential = try? await arcgisCredentialProvider(challenge) { - return .continueWithCredential(arcgisCredential) - } else { - return .cancel - } - } -} diff --git a/Tests/ArcGISToolkitTests/Test Support/Utility/NetworkChallengeHandler.swift b/Tests/ArcGISToolkitTests/Test Support/Utility/NetworkChallengeHandler.swift new file mode 100644 index 000000000..36c9681db --- /dev/null +++ b/Tests/ArcGISToolkitTests/Test Support/Utility/NetworkChallengeHandler.swift @@ -0,0 +1,63 @@ +// +// COPYRIGHT 1995-2022 ESRI +// +// TRADE SECRETS: ESRI PROPRIETARY AND CONFIDENTIAL +// Unpublished material - all rights reserved under the +// Copyright Laws of the United States and applicable international +// laws, treaties, and conventions. +// +// For additional information, contact: +// Environmental Systems Research Institute, Inc. +// Attn: Contracts and Legal Services Department +// 380 New York Street +// Redlands, California, 92373 +// USA +// +// email: contracts@esri.com +// + +import Foundation +import ArcGIS + +/// A `NetworkChallengeHandler` that can handle trusting hosts with a self-signed certificate +/// and the network credential. +final class NetworkChallengeHandler: NetworkAuthenticationChallengeHandler { + /// A Boolean value indicating whether to allow hosts that have certificate trust issues. + let allowUntrustedHosts: Bool + + /// The url credential used when a challenge is thrown. + let networkCredential: NetworkCredential? + + /// The network authentication challenges. + private(set) var challenges: [NetworkAuthenticationChallenge] = [] + + init( + allowUntrustedHosts: Bool, + networkCredential: NetworkCredential? = nil + ) { + self.allowUntrustedHosts = allowUntrustedHosts + self.networkCredential = networkCredential + } + + func handleNetworkAuthenticationChallenge( + _ challenge: NetworkAuthenticationChallenge + ) async -> NetworkAuthenticationChallenge.Disposition { + // Record challenge only if it is not a server trust. + if challenge.kind != .serverTrust { + challenges.append(challenge) + } + + if challenge.kind == .serverTrust { + if allowUntrustedHosts { + // This will cause a self-signed certificate to be trusted. + return .continueWithCredential(.serverTrust) + } else { + return .continueWithoutCredential + } + } else if let networkCredential = networkCredential { + return .continueWithCredential(networkCredential) + } else { + return .cancel + } + } +} diff --git a/Tests/ArcGISToolkitTests/UtilityNetworkTraceViewModelTests.swift b/Tests/ArcGISToolkitTests/UtilityNetworkTraceViewModelTests.swift index 58deb08ed..bf7672700 100644 --- a/Tests/ArcGISToolkitTests/UtilityNetworkTraceViewModelTests.swift +++ b/Tests/ArcGISToolkitTests/UtilityNetworkTraceViewModelTests.swift @@ -23,7 +23,7 @@ import XCTest ArcGISEnvironment.apiKey = apiKey try XCTSkipIf(apiKey == .placeholder) - setChallengeHandler(ChallengeHandler(trustedHosts: [URL.sampleServer7.host!])) + setNetworkChallengeHandler(NetworkChallengeHandler(allowUntrustedHosts: true)) ArcGISEnvironment.authenticationManager.arcGISCredentialStore.add( try await tokenForSampleServer7 ) @@ -32,6 +32,7 @@ import XCTest func tearDownWithError() async throws { ArcGISEnvironment.apiKey = nil ArcGISEnvironment.authenticationManager.arcGISAuthenticationChallengeHandler = nil + ArcGISEnvironment.authenticationManager.networkAuthenticationChallengeHandler = nil ArcGISEnvironment.authenticationManager.arcGISCredentialStore.removeAll() }