diff --git a/AuthenticationExample/AuthenticationExample/AuthenticationApp.swift b/AuthenticationExample/AuthenticationExample/AuthenticationApp.swift index 8d0824cdc..031664479 100644 --- a/AuthenticationExample/AuthenticationExample/AuthenticationApp.swift +++ b/AuthenticationExample/AuthenticationExample/AuthenticationApp.swift @@ -48,6 +48,7 @@ struct AuthenticationApp: App { // - Client Certificate (PKI) .authenticator(authenticator) .environmentObject(authenticator) + .environmentObject(authenticator.smartCardManager) .task { isSettingUp = true // Here we setup credential stores to be persistent, which means that it will @@ -58,6 +59,13 @@ struct AuthenticationApp: App { try? await ArcGISEnvironment.authenticationManager.setupPersistentCredentialStorage(access: .whenUnlockedThisDeviceOnly) isSettingUp = false } + .task { + authenticator.signOutAction = { + isSettingUp = true + await authenticator.signOut() + isSettingUp = false + } + } } } } diff --git a/AuthenticationExample/AuthenticationExample/ProfileView.swift b/AuthenticationExample/AuthenticationExample/ProfileView.swift index 948aa2f6e..1c436282e 100644 --- a/AuthenticationExample/AuthenticationExample/ProfileView.swift +++ b/AuthenticationExample/AuthenticationExample/ProfileView.swift @@ -17,6 +17,9 @@ import ArcGISToolkit /// A view that displays the profile of a user. struct ProfileView: View { + /// The view model used by this selector. + @EnvironmentObject var authenticator: Authenticator + /// The portal that the user is signed in to. @State var portal: Portal @@ -58,8 +61,7 @@ struct ProfileView: View { func signOut() { isSigningOut = true Task { - await ArcGISEnvironment.authenticationManager.revokeOAuthTokens() - await ArcGISEnvironment.authenticationManager.clearCredentialStores() + await authenticator.signOutAction() isSigningOut = false signOutAction() } diff --git a/Sources/ArcGISToolkit/Components/Authentication/Authenticator.swift b/Sources/ArcGISToolkit/Components/Authentication/Authenticator.swift index 74beca8f3..c3b2f9fbe 100644 --- a/Sources/ArcGISToolkit/Components/Authentication/Authenticator.swift +++ b/Sources/ArcGISToolkit/Components/Authentication/Authenticator.swift @@ -14,6 +14,7 @@ import ArcGIS import Combine import CryptoTokenKit +import SwiftUI /// The `Authenticator` is a configurable object that handles authentication challenges. It will /// display a user interface when network and ArcGIS authentication challenges occur. @@ -53,9 +54,16 @@ import CryptoTokenKit public final class Authenticator: ObservableObject { /// A value indicating whether we should prompt the user when encountering an untrusted host. let promptForUntrustedHosts: Bool + /// The OAuth configurations that this authenticator can work with. let oAuthUserConfigurations: [OAuthUserConfiguration] + /// The closure to call once the user has signed out. + public var signOutAction: (() async -> Void) = {} + + /// The smart card manager. + @ObservedObject public var smartCardManager: SmartCardManager + /// Creates an authenticator. /// - Parameters: /// - promptForUntrustedHosts: A value indicating whether we should prompt the user when @@ -67,6 +75,7 @@ public final class Authenticator: ObservableObject { ) { self.promptForUntrustedHosts = promptForUntrustedHosts self.oAuthUserConfigurations = oAuthUserConfigurations + self.smartCardManager = SmartCardManager() } /// The current challenge. @@ -81,6 +90,9 @@ extension Authenticator: ArcGISAuthenticationChallengeHandler { // Alleviates an error with "already presenting". await Task.yield() + // Set last connected smart card. + smartCardManager.setLastConnectedCard() + // Create the correct challenge type. if let configuration = oAuthUserConfigurations.first(where: { $0.canBeUsed(for: challenge.requestURL) }) { do { @@ -108,6 +120,9 @@ extension Authenticator: NetworkAuthenticationChallengeHandler { public func handleNetworkAuthenticationChallenge( _ challenge: NetworkAuthenticationChallenge ) async -> NetworkAuthenticationChallenge.Disposition { + // Set last connected smart card. + smartCardManager.setLastConnectedCard() + // If `promptForUntrustedHosts` is `false` then perform default handling // for server trust challenges. guard promptForUntrustedHosts || challenge.kind != .serverTrust else { @@ -136,3 +151,12 @@ extension Authenticator: NetworkAuthenticationChallengeHandler { return await challengeContinuation.value } } + +public extension Authenticator { + /// Signs out by revoking tokens and clearing the credential stores. + func signOut() async { + await ArcGISEnvironment.authenticationManager.revokeOAuthTokens() + await ArcGISEnvironment.authenticationManager.clearCredentialStores() + smartCardManager.reset() + } +} diff --git a/Sources/ArcGISToolkit/Components/Authentication/AuthenticatorModifier.swift b/Sources/ArcGISToolkit/Components/Authentication/AuthenticatorModifier.swift index f834e34d3..58a8f6dda 100644 --- a/Sources/ArcGISToolkit/Components/Authentication/AuthenticatorModifier.swift +++ b/Sources/ArcGISToolkit/Components/Authentication/AuthenticatorModifier.swift @@ -48,22 +48,24 @@ private struct AuthenticatorModifier: ViewModifier { @ObservedObject var authenticator: Authenticator @ViewBuilder func body(content: Content) -> some View { - switch authenticator.currentChallenge { - case let challenge as TokenChallengeContinuation: - content.modifier(LoginViewModifier(challenge: challenge)) - case let challenge as NetworkChallengeContinuation: - switch challenge.kind { - case .serverTrust: - content.modifier(TrustHostViewModifier(challenge: challenge)) - case .certificate: - content.modifier(CertificatePickerViewModifier(challenge: challenge)) - case .login: + if let currentChallenge = authenticator.currentChallenge { + switch currentChallenge { + case let challenge as TokenChallengeContinuation: content.modifier(LoginViewModifier(challenge: challenge)) + case let challenge as NetworkChallengeContinuation: + switch challenge.kind { + case .serverTrust: + content.modifier(TrustHostViewModifier(challenge: challenge)) + case .certificate: + content.modifier(CertificatePickerViewModifier(challenge: challenge)) + case .login: + content.modifier(LoginViewModifier(challenge: challenge)) + } + default: + fatalError("unknown challenge type") } - case .none: - content - default: - fatalError("unknown challenge type") + } else { + content.modifier(SmartCardViewModifier()) } } } diff --git a/Sources/ArcGISToolkit/Components/Authentication/SmartCardManager.swift b/Sources/ArcGISToolkit/Components/Authentication/SmartCardManager.swift new file mode 100644 index 000000000..fc8b4aa63 --- /dev/null +++ b/Sources/ArcGISToolkit/Components/Authentication/SmartCardManager.swift @@ -0,0 +1,100 @@ +// 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 Foundation +import CryptoTokenKit +import ArcGIS + +@MainActor +public final class SmartCardManager: ObservableObject { + /// An enumeration of the various status values for a smart card connection. + public enum ConnectionStatus { + /// A smart card is connected to the device. + case connected + /// A smart card is disconnected from the device. + case disconnected + /// The connection is unspecified. + case unspecified + } + + /// The current smart card connection status. + @Published public var connectionStatus: ConnectionStatus = .unspecified + + /// A Boolean value indicating whether the smart card is disconnected. + @Published var isCardDisconnected: Bool = false + + /// A Boolean value indicating whether a different smart card is connected. + @Published var isDifferentCardConnected: Bool = false + + /// The smart card connection watcher. + private let watcher = TKTokenWatcher() + + /// The last connected smart card. + private var lastConnectedCard: String? = nil + + /// Creates smart card manager. + init() { + // Monitor the smart card connection. + watcher.setInsertionHandler { [weak self] tokenID in + guard let self = self, tokenID.localizedCaseInsensitiveContains("pivtoken") else { return } + + if let lastConnectedCard, tokenID != lastConnectedCard { + DispatchQueue.main.async { + self.isDifferentCardConnected = true + } + } else { + lastConnectedCard = tokenID + } + + DispatchQueue.main.async { + if self.connectionStatus != .connected { + self.connectionStatus = .connected + } + } + + watcher.addRemovalHandler( { [weak self] tokenID in + guard let self = self else { return } + + if tokenID == lastConnectedCard { + DispatchQueue.main.async { + self.connectionStatus = .disconnected + self.isCardDisconnected = true + } + } + }, forTokenID: tokenID) + } + } + + /// The first PIV token found in the token watcher. + /// - Note: The PIV token will be available only if smart card is connected to the device. + var pivToken: String? { + watcher.tokenIDs.filter({ $0.localizedCaseInsensitiveContains("pivtoken") }).first + } + + /// Sets the last connected card if PIV token is available. This is being called from the + /// authentication challenge handler to monitor the smart card. + func setLastConnectedCard() { + guard let pivToken = pivToken else { return } + if lastConnectedCard == nil { + lastConnectedCard = pivToken + } + } + + /// Resets the smart card manager. + func reset() { + lastConnectedCard = nil + isCardDisconnected = false + isDifferentCardConnected = false + connectionStatus = .unspecified + } +} diff --git a/Sources/ArcGISToolkit/Components/Authentication/SmartCardViewModifier.swift b/Sources/ArcGISToolkit/Components/Authentication/SmartCardViewModifier.swift new file mode 100644 index 000000000..8468a5501 --- /dev/null +++ b/Sources/ArcGISToolkit/Components/Authentication/SmartCardViewModifier.swift @@ -0,0 +1,98 @@ +// 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 SwiftUI +import ArcGIS + +/// A view modifier that prompts the alerts for smart card. +struct SmartCardViewModifier: ViewModifier { + /// The authenticator. + @EnvironmentObject var authenticator: Authenticator + + /// The smart card manager. + @EnvironmentObject var smartCardManager: SmartCardManager + + func body(content: Content) -> some View { + content + .promptDisconnectedCard( + isPresented: $smartCardManager.isCardDisconnected, + authenticator: authenticator + ) + .promptDifferentCardConnected( + isPresented: $smartCardManager.isDifferentCardConnected, + authenticator: authenticator + ) + } +} + +private extension View { + /// Displays an alert to the user to let them know that the smart card is disconnected. + /// - Parameters: + /// - isPresented: A binding to a Boolean value that determines whether to present an alert. + /// - authenticator: The authenticator. + @MainActor @ViewBuilder func promptDisconnectedCard( + isPresented: Binding, + authenticator: Authenticator + ) -> some View { + alert( + Text("Smart Card Disconnected", bundle: .toolkitModule), + isPresented: isPresented + ) { + Button(role: .cancel) { + Task { + await authenticator.signOutAction() + } + } label: { + Text("Sign Out", bundle: .toolkitModule) + } + Button(role: .destructive) { + print("Continue") + } label: { + Text("Continue", bundle: .toolkitModule) + } + } message: { + Text( + "Connect a smart card and continue or sign out to access a different account.", + bundle: .toolkitModule + ) + } + } +} + +private extension View { + /// Displays a prompt to the user to let them know that the smart card is disconnected. + /// - Parameters: isPresented: A Boolean value indicating if the view is presented. + @MainActor @ViewBuilder func promptDifferentCardConnected( + isPresented: Binding, + authenticator: Authenticator + ) -> some View { + alert( + Text("Different Card Connected", bundle: .toolkitModule), + isPresented: isPresented + ) { + Button(role: .cancel) { + Task { + await authenticator.signOutAction() + } + } label: { + Text("Sign Out", bundle: .toolkitModule) + } + } message: { + Text( + "You must sign out to access a different account.", + bundle: .toolkitModule + ) + } + } +} +