Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

added smart card manager support in authenticator #370

Closed
wants to merge 12 commits into from
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
}
}
}
}
Expand Down
6 changes: 4 additions & 2 deletions AuthenticationExample/AuthenticationExample/ProfileView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -67,6 +75,7 @@ public final class Authenticator: ObservableObject {
) {
self.promptForUntrustedHosts = promptForUntrustedHosts
self.oAuthUserConfigurations = oAuthUserConfigurations
self.smartCardManager = SmartCardManager()
}

/// The current challenge.
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
}
100 changes: 100 additions & 0 deletions Sources/ArcGISToolkit/Components/Authentication/SmartCardManager.swift
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +1 to +12
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// 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.
// 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
//
// https://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
}
}
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +1 to +12
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// 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.
// 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
//
// https://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<Bool>,
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<Bool>,
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
)
}
}
}