diff --git a/swift/apple/Firezone.xcodeproj/project.pbxproj b/swift/apple/Firezone.xcodeproj/project.pbxproj
index b47e0d242..fad61b3ca 100644
--- a/swift/apple/Firezone.xcodeproj/project.pbxproj
+++ b/swift/apple/Firezone.xcodeproj/project.pbxproj
@@ -546,7 +546,7 @@
MARKETING_VERSION = 1.0;
ONLY_ACTIVE_ARCH = YES;
OTHER_LDFLAGS = "-lconnlib";
- PRODUCT_BUNDLE_IDENTIFIER = "$(inherited).network-extension";
+ PRODUCT_BUNDLE_IDENTIFIER = "${APP_ID}.network-extension";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
@@ -587,7 +587,7 @@
MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = "-lconnlib";
- PRODUCT_BUNDLE_IDENTIFIER = "$(inherited).network-extension";
+ PRODUCT_BUNDLE_IDENTIFIER = "${APP_ID}.network-extension";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
@@ -629,7 +629,7 @@
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(CONNLIB_TARGET_DIR)/x86_64-apple-darwin/debug";
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = "-lconnlib";
- PRODUCT_BUNDLE_IDENTIFIER = "$(inherited).network-extension";
+ PRODUCT_BUNDLE_IDENTIFIER = "${APP_ID}.network-extension";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = macosx;
SKIP_INSTALL = YES;
@@ -670,7 +670,7 @@
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(CONNLIB_TARGET_DIR)/x86_64-apple-darwin/release";
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = "-lconnlib";
- PRODUCT_BUNDLE_IDENTIFIER = "$(inherited).network-extension";
+ PRODUCT_BUNDLE_IDENTIFIER = "${APP_ID}.network-extension";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = macosx;
SKIP_INSTALL = YES;
@@ -864,6 +864,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "${APP_ID}";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
@@ -915,6 +916,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "${APP_ID}";
PRODUCT_MODULE_NAME = "$(PRODUCT_NAME:c99extidentifier)";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
diff --git a/swift/apple/Firezone/Firezone.entitlements b/swift/apple/Firezone/Firezone.entitlements
index 4e920afc0..247e6597f 100644
--- a/swift/apple/Firezone/Firezone.entitlements
+++ b/swift/apple/Firezone/Firezone.entitlements
@@ -6,6 +6,10 @@
packet-tunnel-provider
+ com.apple.security.application-groups
+
+ ${APP_GROUP_ID}
+
com.apple.security.app-sandbox
com.apple.security.network.client
diff --git a/swift/apple/Firezone/Info.plist b/swift/apple/Firezone/Info.plist
index a0133aefd..ab64852b2 100644
--- a/swift/apple/Firezone/Info.plist
+++ b/swift/apple/Firezone/Info.plist
@@ -15,6 +15,8 @@
+ AppGroupIdentifier
+ ${APP_GROUP_ID}
AuthURLScheme
$(AUTH_URL_SCHEME)
AuthURLHost
diff --git a/swift/apple/Firezone/xcconfig/Build.xcconfig b/swift/apple/Firezone/xcconfig/Build.xcconfig
index f7ab44197..e5b68e806 100644
--- a/swift/apple/Firezone/xcconfig/Build.xcconfig
+++ b/swift/apple/Firezone/xcconfig/Build.xcconfig
@@ -1,2 +1,5 @@
CONNLIB_SOURCE_DIR=${PROJECT_DIR}/../../rust/connlib/clients/apple
CONNLIB_TARGET_DIR=${PROJECT_DIR}/../../rust/target
+
+APP_GROUP_ID[sdk=macosx*] = ${DEVELOPMENT_TEAM}.group.${APP_ID}
+APP_GROUP_ID[sdk=iphoneos*] = group.${APP_ID}
diff --git a/swift/apple/Firezone/xcconfig/Developer.xcconfig.ci-iOS b/swift/apple/Firezone/xcconfig/Developer.xcconfig.ci-iOS
index 01a3f3b00..7a10f0302 100644
--- a/swift/apple/Firezone/xcconfig/Developer.xcconfig.ci-iOS
+++ b/swift/apple/Firezone/xcconfig/Developer.xcconfig.ci-iOS
@@ -1,2 +1,2 @@
DEVELOPMENT_TEAM = 0000000000
-PRODUCT_BUNDLE_IDENTIFIER = dev.firezone.ios
+APP_ID = dev.firezone.ios
diff --git a/swift/apple/Firezone/xcconfig/Developer.xcconfig.ci-macOS b/swift/apple/Firezone/xcconfig/Developer.xcconfig.ci-macOS
index f1fad4428..8d45d4b25 100644
--- a/swift/apple/Firezone/xcconfig/Developer.xcconfig.ci-macOS
+++ b/swift/apple/Firezone/xcconfig/Developer.xcconfig.ci-macOS
@@ -1,2 +1,2 @@
DEVELOPMENT_TEAM = 0000000000
-PRODUCT_BUNDLE_IDENTIFIER = dev.firezone.macos
+APP_ID = dev.firezone.macos
diff --git a/swift/apple/Firezone/xcconfig/Developer.xcconfig.template b/swift/apple/Firezone/xcconfig/Developer.xcconfig.template
index e1fd00e2c..d006cb2ed 100644
--- a/swift/apple/Firezone/xcconfig/Developer.xcconfig.template
+++ b/swift/apple/Firezone/xcconfig/Developer.xcconfig.template
@@ -4,4 +4,4 @@ DEVELOPMENT_TEAM =
// The bundle identifier of the apps.
// Should be an app id created at developer.apple.com
// with Network Extensions capability.
-PRODUCT_BUNDLE_IDENTIFIER =
+APP_ID =
diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AppView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AppView.swift
index cc6e9d7aa..662db4771 100644
--- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AppView.swift
+++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AppView.swift
@@ -17,12 +17,9 @@ public final class AppViewModel: ObservableObject {
public init() {
Task {
- let tunnel = try await TunnelStore.loadOrCreate()
self.welcomeViewModel = WelcomeViewModel(
appStore: AppStore(
- tunnelStore: TunnelStore(
- tunnel: tunnel
- )
+ tunnelStore: TunnelStore.shared
)
)
}
diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AuthView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AuthView.swift
index 63a33124b..ad9f45d54 100644
--- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AuthView.swift
+++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AuthView.swift
@@ -12,7 +12,6 @@ import XCTestDynamicOverlay
@MainActor
final class AuthViewModel: ObservableObject {
- @Dependency(\.settingsClient) private var settingsClient
@Dependency(\.authStore) private var authStore
var settingsUndefined: () -> Void = unimplemented("\(AuthViewModel.self).settingsUndefined")
@@ -20,13 +19,14 @@ final class AuthViewModel: ObservableObject {
private var cancellables = Set()
func signInButtonTapped() async {
- guard let teamId = settingsClient.fetchSettings()?.teamId, !teamId.isEmpty else {
+ guard let accountId = authStore.tunnelStore.tunnelAuthStatus.accountId(),
+ !accountId.isEmpty else {
settingsUndefined()
return
}
do {
- try await authStore.signIn(teamId: teamId)
+ try await authStore.signIn(accountId: accountId)
} catch {
dump(error)
}
diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/MainView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/MainView.swift
index 259968358..e89fabb6a 100644
--- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/MainView.swift
+++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/MainView.swift
@@ -60,17 +60,22 @@ final class MainViewModel: ObservableObject {
}
func signOutButtonTapped() {
- appStore.auth.signOut()
+ Task {
+ do {
+ try await appStore.auth.signOut()
+ } catch {
+ logger.error("Error signing out: \(String(describing: error))")
+ }
+ }
}
func startTunnel() async {
do {
- if case .signedIn(let authResponse) = self.loginStatus {
- try await appStore.tunnel.start(authResponse: authResponse)
+ if case .signedIn = self.loginStatus {
+ try await appStore.tunnel.start()
}
} catch {
- logger.error("Error starting tunnel: \(String(describing: error)) -- signing out")
- appStore.auth.signOut()
+ logger.error("Error starting tunnel: \(String(describing: error))")
}
}
@@ -87,11 +92,11 @@ struct MainView: View {
Section(header: Text("Authentication")) {
Group {
switch self.model.loginStatus {
- case .signedIn(let authResponse):
+ case .signedIn(_, let actorName):
HStack {
- Text(authResponse.actorName == nil ? "Signed in" : "Signed in as")
+ Text(actorName.isEmpty ? "Signed in" : "Signed in as")
Spacer()
- Text(authResponse.actorName ?? "")
+ Text(actorName)
.foregroundColor(.secondary)
}
HStack {
@@ -165,9 +170,7 @@ struct MainView_Previews: PreviewProvider {
MainView(
model: MainViewModel(
appStore: AppStore(
- tunnelStore: TunnelStore(
- tunnel: NETunnelProviderManager()
- )
+ tunnelStore: TunnelStore.shared
)
)
)
diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/SettingsView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/SettingsView.swift
index 11eb5930b..713d0e664 100644
--- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/SettingsView.swift
+++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/SettingsView.swift
@@ -7,29 +7,51 @@
import Dependencies
import SwiftUI
import XCTestDynamicOverlay
+import Combine
public final class SettingsViewModel: ObservableObject {
- @Dependency(\.settingsClient) private var settingsClient
+ @Dependency(\.authStore) private var authStore
@Published var settings: Settings
public var onSettingsSaved: () -> Void = unimplemented()
+ private var cancellables = Set()
public init() {
settings = Settings()
-
load()
}
func load() {
- if let storedSettings = settingsClient.fetchSettings() {
- settings = storedSettings
+ Task {
+ authStore.tunnelStore.$tunnelAuthStatus
+ .filter { $0.isInitialized }
+ .receive(on: RunLoop.main)
+ .sink { [weak self] tunnelAuthStatus in
+ guard let self = self else { return }
+ self.settings = Settings(accountId: tunnelAuthStatus.accountId() ?? "")
+ }
+ .store(in: &cancellables)
}
}
func save() {
- settingsClient.saveSettings(settings)
- onSettingsSaved()
+ Task {
+ let accountId = await authStore.loginStatus.accountId
+ if accountId == settings.accountId {
+ // Not changed
+ return
+ }
+ let tunnelAuthStatus: TunnelAuthStatus = await {
+ if settings.accountId.isEmpty {
+ return .accountNotSetup
+ } else {
+ return await authStore.tunnelAuthStatusForAccount(accountId: settings.accountId)
+ }
+ }()
+ try await authStore.tunnelStore.setAuthStatus(tunnelAuthStatus)
+ onSettingsSaved()
+ }
}
}
@@ -69,7 +91,7 @@ public struct SettingsView: View {
Button("Save") {
self.saveButtonTapped()
}
- .disabled(!isTeamIdValid(model.settings.teamId))
+ .disabled(!isTeamIdValid(model.settings.accountId))
}
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
@@ -92,7 +114,7 @@ public struct SettingsView: View {
Button("Save", action: {
self.saveButtonTapped()
})
- .disabled(!isTeamIdValid(model.settings.teamId))
+ .disabled(!isTeamIdValid(model.settings.accountId))
}
}
}
@@ -102,12 +124,12 @@ public struct SettingsView: View {
Form {
Section {
FormTextField(
- title: "Team ID:",
- baseURLString: AuthStore.getAuthBaseURLFromInfoPlist().absoluteString,
- placeholder: "team-id",
+ title: "Account ID:",
+ baseURLString: AppInfoPlistConstants.authBaseURL.absoluteString,
+ placeholder: "account-id",
text: Binding(
- get: { model.settings.teamId },
- set: { model.settings.teamId = $0 }
+ get: { model.settings.accountId },
+ set: { model.settings.accountId = $0 }
)
)
}
diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/WelcomeView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/WelcomeView.swift
index 362ef0266..496749ec6 100644
--- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/WelcomeView.swift
+++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/WelcomeView.swift
@@ -13,7 +13,6 @@ import SwiftUINavigation
#if os(iOS)
@MainActor
final class WelcomeViewModel: ObservableObject {
- @Dependency(\.settingsClient) private var settingsClient
@Dependency(\.mainQueue) private var mainQueue
private var cancellables = Set()
@@ -56,7 +55,7 @@ final class WelcomeViewModel: ObservableObject {
defer { bindDestination() }
- if settingsClient.fetchSettings()?.teamId == nil {
+ if case .accountNotSetup = appStore.tunnel.tunnelAuthStatus {
destination = .undefinedSettingsAlert(.undefinedSettings)
}
@@ -154,7 +153,7 @@ struct WelcomeView: View {
struct WelcomeView_Previews: PreviewProvider {
static var previews: some View {
WelcomeView(
- model: WelcomeViewModel(appStore: AppStore(tunnelStore: TunnelStore(tunnel: .init())))
+ model: WelcomeViewModel(appStore: AppStore(tunnelStore: TunnelStore.shared))
)
}
}
diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/AppInfoPlistConstants.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/AppInfoPlistConstants.swift
new file mode 100644
index 000000000..e3947c809
--- /dev/null
+++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/AppInfoPlistConstants.swift
@@ -0,0 +1,47 @@
+//
+// AppInfoPlistConstants.swift
+// (c) 2023 Firezone, Inc.
+// LICENSE: Apache-2.0
+//
+
+import Foundation
+
+struct AppInfoPlistConstants {
+
+ static var authBaseURL: URL {
+ let infoPlistDictionary = Bundle.main.infoDictionary
+ guard let urlScheme = (infoPlistDictionary?["AuthURLScheme"] as? String), !urlScheme.isEmpty else {
+ fatalError("AuthURLScheme missing in app's Info.plist. Please define AUTH_URL_SCHEME, AUTH_URL_HOST, CONTROL_PLANE_URL_SCHEME, and CONTROL_PLANE_URL_HOST in Server.xcconfig.")
+ }
+ guard let urlHost = (infoPlistDictionary?["AuthURLHost"] as? String), !urlHost.isEmpty else {
+ fatalError("AuthURLHost missing in app's Info.plist. Please define AUTH_URL_SCHEME, AUTH_URL_HOST, CONTROL_PLANE_URL_SCHEME, and CONTROL_PLANE_URL_HOST in Server.xcconfig.")
+ }
+ let urlString = "\(urlScheme)://\(urlHost)/"
+ guard let url = URL(string: urlString) else {
+ fatalError("AuthURL: Cannot form valid URL from string: \(urlString)")
+ }
+ return url
+ }
+
+ static var controlPlaneURL: URL {
+ let infoPlistDictionary = Bundle.main.infoDictionary
+ guard let urlScheme = (infoPlistDictionary?["ControlPlaneURLScheme"] as? String), !urlScheme.isEmpty else {
+ fatalError("ControlPlaneURLScheme missing in app's Info.plist. Please define AUTH_URL_SCHEME, AUTH_URL_HOST, CONTROL_PLANE_URL_SCHEME, and CONTROL_PLANE_URL_HOST in Server.xcconfig.")
+ }
+ guard let urlHost = (infoPlistDictionary?["ControlPlaneURLHost"] as? String), !urlHost.isEmpty else {
+ fatalError("ControlPlaneURLHost missing in app's Info.plist. Please define AUTH_URL_SCHEME, AUTH_URL_HOST, CONTROL_PLANE_URL_SCHEME, and CONTROL_PLANE_URL_HOST in Server.xcconfig.")
+ }
+ let urlString = "\(urlScheme)://\(urlHost)/"
+ guard let url = URL(string: urlString) else {
+ fatalError("ControlPlaneURL: Cannot form valid URL from string: \(urlString)")
+ }
+ return url
+ }
+
+ static var appGroupId: String {
+ guard let appGroupId = Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String else {
+ fatalError("AppGroupIdentifier missing in app's Info.plist")
+ }
+ return appGroupId
+ }
+}
diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/Keychain+AuthResponse.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/Keychain+AuthResponse.swift
deleted file mode 100644
index 7b55ede72..000000000
--- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/Keychain+AuthResponse.swift
+++ /dev/null
@@ -1,41 +0,0 @@
-//
-// Keychain+AuthResponse.swift
-// (c) 2023 Firezone, Inc.
-// LICENSE: Apache-2.0
-//
-
-extension KeychainStorage {
- static let tokenKey = "token"
- static let actorNameKey = "actorName"
-
- func token() async throws -> String? {
- let token = try await load(KeychainStorage.tokenKey).flatMap { data in
- String(data: data, encoding: .utf8)
- }
-
- guard let token else { return nil }
- return token
- }
-
- func actorName() async throws -> String? {
- let actorName = try await load(KeychainStorage.actorNameKey).flatMap { data in
- String(data: data, encoding: .utf8)
- }
-
- guard let actorName else { return nil }
- return actorName
- }
-
- func save(token: String, actorName: String?) async throws {
- try await store(KeychainStorage.tokenKey, token.data(using: .utf8)!)
-
- if let actorName {
- try await store(KeychainStorage.actorNameKey, actorName.data(using: .utf8)!)
- }
- }
-
- func deleteAuthResponse() async throws {
- try await delete(KeychainStorage.tokenKey)
- try await delete(KeychainStorage.actorNameKey)
- }
-}
diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/Keychain.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/Keychain.swift
index 9f722caa5..ebf1678fe 100644
--- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/Keychain.swift
+++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/Keychain.swift
@@ -6,90 +6,240 @@
import Foundation
-enum KeychainError: Error {
+public enum KeychainError: Error {
case securityError(Status)
+ case appleSecError(call: String, status: Keychain.SecStatus)
+ case nilResultFromAppleSecCall(call: String)
+ case resultFromAppleSecCallIsInvalid(call: String)
+ case unableToFindSavedItem
+ case unableToGetAppGroupIdFromInfoPlist
+ case unableToFormExtensionPath
+ case unableToGetPluginsPath
}
-actor Keychain {
+public actor Keychain {
private static let account = "Firezone"
+ private let workQueue = DispatchQueue(label: "FirezoneKeychainWorkQueue")
- func store(key: String, data: Data) throws {
- let query = ([
- kSecClass: kSecClassGenericPassword,
- kSecAttrService: getServiceIdentifier(key),
- kSecAttrAccount: Keychain.account,
- kSecValueData: data,
- ] as [CFString: Any]) as CFDictionary
+ public typealias Token = String
+ public typealias PersistentRef = Data
- let status = SecItemAdd(query, nil)
+ public struct TokenAttributes {
+ let authURLString: String
+ let actorName: String
+ }
- if status == Status.duplicateItem {
- try update(key: key, data: data)
- } else if status != Status.success {
- throw securityError(status)
+ public enum SecStatus: Equatable {
+ case status(Status)
+ case unknownStatus(OSStatus)
+
+ init(_ osStatus: OSStatus) {
+ if let status = Status(rawValue: osStatus) {
+ self = .status(status)
+ } else {
+ self = .unknownStatus(osStatus)
+ }
+ }
+
+ var isSuccess: Bool {
+ return self == .status(.success)
}
}
- func update(key: String, data: Data) throws {
- let query = ([
+ public init() {
+ }
+
+ func store(token: Token, tokenAttributes: TokenAttributes) async throws -> PersistentRef {
+ #if os(iOS)
+ let query = [
+ // Common for both iOS and macOS:
kSecClass: kSecClassGenericPassword,
- kSecAttrService: getServiceIdentifier(key),
- kSecAttrAccount: Keychain.account,
- ] as [CFString: Any]) as CFDictionary
-
- let updatedData = [kSecValueData: data] as CFDictionary
-
- let status = SecItemUpdate(query, updatedData)
-
- if status != Status.success {
- throw securityError(status)
+ kSecAttrLabel: "Firezone access token (\(tokenAttributes.actorName))",
+ kSecAttrDescription: "Firezone access token",
+ kSecAttrService: tokenAttributes.authURLString,
+ kSecAttrAccount: "\(tokenAttributes.actorName): \(UUID().uuidString)", // The UUID uniquifies this item in the keychain
+ kSecValueData: token.data(using: .utf8) as Any,
+ kSecReturnPersistentRef: true,
+ kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock,
+ // Specific to iOS:
+ kSecAttrAccessGroup: AppInfoPlistConstants.appGroupId as CFString as Any
+ ] as [CFString: Any]
+ #elseif os(macOS)
+ let query = [
+ // Common for both iOS and macOS:
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrLabel: "Firezone access token (\(tokenAttributes.actorName))",
+ kSecAttrDescription: "Firezone access token",
+ kSecAttrService: tokenAttributes.authURLString,
+ kSecAttrAccount: "\(tokenAttributes.actorName): \(UUID().uuidString)", // The UUID uniquifies this item in the keychain
+ kSecValueData: token.data(using: .utf8) as Any,
+ kSecReturnPersistentRef: true,
+ kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock,
+ // Specific to macOS:
+ kSecAttrAccess: try secAccessForAppAndNetworkExtension()
+ ] as [CFString: Any]
+ #endif
+ return try await withCheckedThrowingContinuation { [weak self] continuation in
+ self?.workQueue.async {
+ var ref: CFTypeRef?
+ let ret = SecStatus(SecItemAdd(query as CFDictionary, &ref))
+ guard ret.isSuccess else {
+ continuation.resume(throwing: KeychainError.appleSecError(call: "SecItemAdd", status: ret))
+ return
+ }
+ guard let savedPersistentRef = ref as? Data else {
+ continuation.resume(throwing: KeychainError.nilResultFromAppleSecCall(call: "SecItemAdd"))
+ return
+ }
+ // Remove any other keychain items for the same service URL
+ var checkForStaleItemsResult: CFTypeRef?
+ let checkForStaleItemsQuery = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrService: tokenAttributes.authURLString,
+ kSecMatchLimit: kSecMatchLimitAll,
+ kSecReturnPersistentRef: true
+ ] as [CFString: Any]
+ let checkRet = SecStatus(SecItemCopyMatching(checkForStaleItemsQuery as CFDictionary, &checkForStaleItemsResult))
+ var isSavedItemFound = false
+ if checkRet.isSuccess, let allRefs = checkForStaleItemsResult as? [Data] {
+ for ref in allRefs {
+ if ref == savedPersistentRef {
+ isSavedItemFound = true
+ } else {
+ SecItemDelete([kSecValuePersistentRef: ref] as CFDictionary)
+ }
+ }
+ }
+ guard isSavedItemFound else {
+ continuation.resume(throwing: KeychainError.unableToFindSavedItem)
+ return
+ }
+ continuation.resume(returning: savedPersistentRef)
+ }
}
}
- func load(key: String) throws -> Data? {
- let query = ([
- kSecClass: kSecClassGenericPassword,
- kSecAttrService: getServiceIdentifier(key),
- kSecAttrAccount: Keychain.account,
- kSecReturnData: kCFBooleanTrue!,
- kSecMatchLimit: kSecMatchLimitOne,
- ] as [CFString: Any]) as CFDictionary
-
- var data: AnyObject?
-
- let status = SecItemCopyMatching(query, &data)
-
- if status == Status.success {
- return data as? Data
- } else if status == Status.itemNotFound {
- return nil
+ #if os(macOS)
+ private func secAccessForAppAndNetworkExtension() throws -> SecAccess {
+ // Creating a trusted-application-based SecAccess APIs are deprecated in favour of
+ // data-protection keychain APIs. However, data-protection keychain doesn't support
+ // accessing from non-userspace processes, like the tunnel process, so we can only
+ // use the deprecated APIs for now.
+ func secTrustedApplicationForPath(_ path: String?) throws -> SecTrustedApplication? {
+ var trustedApp: SecTrustedApplication?
+ let ret = SecStatus(SecTrustedApplicationCreateFromPath(path, &trustedApp))
+ guard ret.isSuccess else {
+ throw KeychainError.appleSecError(call: "SecTrustedApplicationCreateFromPath", status: ret)
+ }
+ if let trustedApp = trustedApp {
+ return trustedApp
+ } else {
+ throw KeychainError.nilResultFromAppleSecCall(call: "SecTrustedApplicationCreateFromPath(\(path ?? "nil"))")
+ }
+ }
+ guard let pluginsURL = Bundle.main.builtInPlugInsURL else {
+ throw KeychainError.unableToGetPluginsPath
+ }
+ let extensionPath = pluginsURL.appendingPathComponent("FirezoneNetworkExtensionmacOS.appex", isDirectory: true).path
+ let trustedApps = [
+ try secTrustedApplicationForPath(nil),
+ try secTrustedApplicationForPath(extensionPath)
+ ]
+ var access: SecAccess?
+ let ret = SecStatus(SecAccessCreate("Firezone Token" as CFString, trustedApps as CFArray, &access))
+ guard ret.isSuccess else {
+ throw KeychainError.appleSecError(call: "SecAccessCreate", status: ret)
+ }
+ if let access = access {
+ return access
} else {
- throw securityError(status)
+ throw KeychainError.nilResultFromAppleSecCall(call: "SecAccessCreate")
+ }
+ }
+ #endif
+
+ // This function is public because the tunnel needs to call it to get the token
+ public func load(persistentRef: PersistentRef) async -> Token? {
+ return await withCheckedContinuation { [weak self] continuation in
+ self?.workQueue.async {
+ let query = [
+ kSecValuePersistentRef: persistentRef,
+ kSecReturnData: true
+ ] as [CFString: Any]
+ var result: CFTypeRef?
+ let ret = SecStatus(SecItemCopyMatching(query as CFDictionary, &result))
+ if ret.isSuccess,
+ let resultData = result as? Data,
+ let resultString = String(data: resultData, encoding: .utf8) {
+ continuation.resume(returning: resultString)
+ } else {
+ continuation.resume(returning: nil)
+ }
+ }
}
}
- func delete(key: String) throws {
- let query = ([
- kSecClass: kSecClassGenericPassword,
- kSecAttrService: getServiceIdentifier(key),
- kSecAttrAccount: Keychain.account,
- ] as [CFString: Any]) as CFDictionary
-
- let status = SecItemDelete(query)
-
- if status != Status.success {
- throw securityError(status)
+ func loadAttributes(persistentRef: PersistentRef) async -> TokenAttributes? {
+ return await withCheckedContinuation { [weak self] continuation in
+ self?.workQueue.async {
+ let query = [
+ kSecValuePersistentRef: persistentRef,
+ kSecReturnAttributes: true
+ ] as [CFString: Any]
+ var result: CFTypeRef?
+ let ret = SecStatus(SecItemCopyMatching(query as CFDictionary, &result))
+ if ret.isSuccess, let result = result {
+ if CFGetTypeID(result) == CFDictionaryGetTypeID() {
+ let cfDict = result as! CFDictionary
+ let dict = cfDict as NSDictionary
+ if let service = dict[kSecAttrService] as? String,
+ let account = dict[kSecAttrAccount] as? String {
+ let actorName = String(account[account.startIndex ..< (account.lastIndex(of: ":") ?? account.endIndex)])
+ let attributes = TokenAttributes(
+ authURLString: service,
+ actorName: actorName)
+ continuation.resume(returning: attributes)
+ return
+ }
+ }
+ }
+ continuation.resume(returning: nil)
+ }
}
}
- private func getServiceIdentifier(_ key: String) -> String {
- var bundleIdentifier = Bundle.main.bundleIdentifier ?? "dev.firezone.firezone"
-
- if bundleIdentifier.hasSuffix(".network-extension") {
- bundleIdentifier.removeLast(".network-extension".count)
+ func delete(persistentRef: PersistentRef) async throws {
+ return try await withCheckedThrowingContinuation { [weak self] continuation in
+ self?.workQueue.async {
+ let query = [kSecValuePersistentRef: persistentRef] as [CFString: Any]
+ let ret = SecStatus(SecItemDelete(query as CFDictionary))
+ guard (ret.isSuccess || ret == .status(.itemNotFound)) else {
+ continuation.resume(throwing: KeychainError.appleSecError(call: "SecItemDelete", status: ret))
+ return
+ }
+ continuation.resume(returning: ())
+ }
}
+ }
- return bundleIdentifier + "." + key
+ func search(authURLString: String) async -> PersistentRef? {
+ return await withCheckedContinuation { [weak self] continuation in
+ self?.workQueue.async {
+ let query = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrDescription: "Firezone access token",
+ kSecAttrService: authURLString,
+ kSecReturnPersistentRef: true,
+ ] as [CFString: Any]
+ var result: CFTypeRef?
+ let ret = SecStatus(SecItemCopyMatching(query as CFDictionary, &result))
+ if ret.isSuccess, let tokenRef = result as? Data {
+ continuation.resume(returning: tokenRef)
+ } else {
+ continuation.resume(returning: nil)
+ }
+ }
+ }
}
private func securityError(_ status: OSStatus) -> Error {
diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/KeychainStorage.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/KeychainStorage.swift
index a379af9a2..d02721135 100644
--- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/KeychainStorage.swift
+++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/KeychainStorage.swift
@@ -8,9 +8,10 @@ import Dependencies
import Foundation
struct KeychainStorage: Sendable {
- var store: @Sendable (String, Data) async throws -> Void
- var load: @Sendable (String) async throws -> Data?
- var delete: @Sendable (String) async throws -> Void
+ var store: @Sendable (Keychain.Token, Keychain.TokenAttributes) async throws -> Keychain.PersistentRef
+ var delete: @Sendable (Keychain.PersistentRef) async throws -> Void
+ var loadAttributes: @Sendable (Keychain.PersistentRef) async -> Keychain.TokenAttributes?
+ var searchByAuthURL: @Sendable (URL) async -> Keychain.PersistentRef?
}
extension KeychainStorage: DependencyKey {
@@ -18,25 +19,33 @@ extension KeychainStorage: DependencyKey {
let keychain = Keychain()
return KeychainStorage(
- store: { try await keychain.store(key: $0, data: $1) },
- load: { try await keychain.load(key: $0) },
- delete: { try await keychain.delete(key: $0) }
+ store: { try await keychain.store(token: $0, tokenAttributes: $1) },
+ delete: { try await keychain.delete(persistentRef: $0) },
+ loadAttributes: { await keychain.loadAttributes(persistentRef: $0) },
+ searchByAuthURL: { await keychain.search(authURLString: $0.absoluteString) }
)
}
static var testValue: KeychainStorage {
- let storage = LockIsolated([String: Data]())
+ let storage = LockIsolated([Data: (Keychain.Token, Keychain.TokenAttributes)]())
return KeychainStorage(
- store: { key, data in
+ store: { token, attributes in
storage.withValue {
- $0[key] = data
+ let uuid = UUID().uuidString.data(using: .utf8)!
+ $0[uuid] = (token, attributes)
+ return uuid
}
},
- load: { storage.value[$0] },
- delete: { key in
+ delete: { ref in
storage.withValue {
- $0[key] = nil
+ $0[ref] = nil
}
+ },
+ loadAttributes: { ref in
+ storage.value[ref]?.1
+ },
+ searchByAuthURL: { authURL in
+ nil
}
)
}
diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/SettingsClient/Settings.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift
similarity index 82%
rename from swift/apple/FirezoneKit/Sources/FirezoneKit/SettingsClient/Settings.swift
rename to swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift
index 280ea1847..a63279322 100644
--- a/swift/apple/FirezoneKit/Sources/FirezoneKit/SettingsClient/Settings.swift
+++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift
@@ -7,5 +7,5 @@
import Foundation
struct Settings: Codable, Hashable {
- var teamId: String = ""
+ var accountId: String = ""
}
diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/SettingsClient/SettingsClient.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/SettingsClient/SettingsClient.swift
deleted file mode 100644
index 2e6841fd7..000000000
--- a/swift/apple/FirezoneKit/Sources/FirezoneKit/SettingsClient/SettingsClient.swift
+++ /dev/null
@@ -1,44 +0,0 @@
-//
-// SettingsClient.swift
-// (c) 2023 Firezone, Inc.
-// LICENSE: Apache-2.0
-//
-
-import Dependencies
-import Foundation
-
-struct SettingsClient {
- var fetchSettings: () -> Settings?
- var saveSettings: (Settings?) -> Void
-}
-
-extension SettingsClient: DependencyKey {
- static let liveValue = SettingsClient(
- fetchSettings: {
- guard let data = UserDefaults.standard.data(forKey: "settings") else {
- return nil
- }
-
- return try? JSONDecoder().decode(Settings.self, from: data)
- },
- saveSettings: { settings in
- let data = try? JSONEncoder().encode(settings)
- UserDefaults.standard.set(data, forKey: "settings")
- }
- )
-
- static var testValue: SettingsClient {
- let settings = LockIsolated(Settings?.none)
- return SettingsClient(
- fetchSettings: { settings.value },
- saveSettings: { settings.setValue($0) }
- )
- }
-}
-
-extension DependencyValues {
- var settingsClient: SettingsClient {
- get { self[SettingsClient.self] }
- set { self[SettingsClient.self] = newValue }
- }
-}
diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AppStore.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AppStore.swift
index e869ebb11..5c4ba6f81 100644
--- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AppStore.swift
+++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AppStore.swift
@@ -43,12 +43,11 @@ final class AppStore: ObservableObject {
private func handleLoginStatusChanged(_ loginStatus: AuthStore.LoginStatus) async {
switch loginStatus {
- case .signedIn(let authResponse):
+ case .signedIn:
do {
- try await tunnel.start(authResponse: authResponse)
+ try await tunnel.start()
} catch {
- logger.error("Error starting tunnel: \(String(describing: error)) -- signing out")
- auth.signOut()
+ logger.error("Error starting tunnel: \(String(describing: error))")
}
case .signedOut:
tunnel.stop()
@@ -59,6 +58,8 @@ final class AppStore: ObservableObject {
private func signOutAndStopTunnel() {
tunnel.stop()
- auth.signOut()
+ Task {
+ try? await auth.signOut()
+ }
}
}
diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift
index e11e799e8..0e894bf68 100644
--- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift
+++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift
@@ -24,106 +24,118 @@ extension DependencyValues {
final class AuthStore: ObservableObject {
private let logger = Logger.make(for: AuthStore.self)
- static let shared = AuthStore()
+ static let shared = AuthStore(tunnelStore: TunnelStore.shared)
enum LoginStatus {
case uninitialized
- case signedOut
- case signedIn(AuthResponse)
+ case signedOut(accountId: String?)
+ case signedIn(accountId: String, actorName: String)
+
+ var accountId: String? {
+ switch self {
+ case .uninitialized: return nil
+ case .signedOut(let accountId): return accountId
+ case .signedIn(let accountId, _): return accountId
+ }
+ }
}
@Dependency(\.keychain) private var keychain
@Dependency(\.auth) private var auth
- @Dependency(\.settingsClient) private var settingsClient
- private let authBaseURL: URL
+ let tunnelStore: TunnelStore
+
+ public let authBaseURL: URL
private var cancellables = Set()
@Published private(set) var loginStatus: LoginStatus
- private init() {
- self.authBaseURL = Self.getAuthBaseURLFromInfoPlist()
+ private init(tunnelStore: TunnelStore) {
+ self.tunnelStore = tunnelStore
+ self.authBaseURL = AppInfoPlistConstants.authBaseURL
self.loginStatus = .uninitialized
- Task {
- self.loginStatus = await { () -> LoginStatus in
- guard let teamId = settingsClient.fetchSettings()?.teamId else {
- logger.debug("No team-id found in settings")
- return .signedOut
- }
- guard let token = try? await keychain.token() else {
- logger.debug("Token not found in keychain")
- return .signedOut
- }
- guard let actorName = try? await keychain.actorName() else {
- logger.debug("Actor not found in keychain")
- return .signedOut
- }
- let portalURL = self.authURL(teamId: teamId)
- let authResponse = AuthResponse(portalURL: portalURL, token: token, actorName: actorName)
- logger.debug("Token recovered from keychain.")
- return .signedIn(authResponse)
- }()
- }
- $loginStatus
- .sink { [weak self] loginStatus in
- Task { [weak self] in
- switch loginStatus {
- case .signedIn(let authResponse):
- try? await self?.keychain.save(token: authResponse.token, actorName: authResponse.actorName)
- self?.logger.debug("authResponse saved on keychain.")
- case .signedOut:
- try? await self?.keychain.deleteAuthResponse()
- self?.logger.debug("token deleted from keychain.")
- case .uninitialized:
- break
- }
+ tunnelStore.$tunnelAuthStatus
+ .sink { [weak self] tunnelAuthStatus in
+ guard let self = self else { return }
+ Task {
+ self.loginStatus = await self.getLoginStatus(from: tunnelAuthStatus)
}
}
.store(in: &cancellables)
}
- func signIn(teamId: String) async throws {
+ private func getLoginStatus(from tunnelAuthStatus: TunnelAuthStatus) async -> LoginStatus {
+ switch tunnelAuthStatus {
+ case .tunnelUninitialized:
+ return .uninitialized
+ case .accountNotSetup:
+ return .signedOut(accountId: nil)
+ case .signedOut(let tunnelAuthBaseURL, let tunnelAccountId):
+ if self.authBaseURL == tunnelAuthBaseURL {
+ return .signedOut(accountId: tunnelAccountId)
+ } else {
+ return .signedOut(accountId: nil)
+ }
+ case .signedIn(let tunnelAuthBaseURL, let tunnelAccountId, let tokenReference):
+ guard self.authBaseURL == tunnelAuthBaseURL else {
+ return .signedOut(accountId: nil)
+ }
+ let tunnelPortalURLString = self.authURL(accountId: tunnelAccountId).absoluteString
+ guard let tokenAttributes = await keychain.loadAttributes(tokenReference),
+ tunnelPortalURLString == tokenAttributes.authURLString else {
+ return .signedOut(accountId: tunnelAccountId)
+ }
+ return .signedIn(accountId: tunnelAccountId, actorName: tokenAttributes.actorName)
+ }
+ }
+
+ func signIn(accountId: String) async throws {
logger.trace("\(#function)")
- let portalURL = authURL(teamId: teamId)
+ let portalURL = authURL(accountId: accountId)
let authResponse = try await auth.signIn(portalURL)
- self.loginStatus = .signedIn(authResponse)
+ let attributes = Keychain.TokenAttributes(authURLString: portalURL.absoluteString, actorName: authResponse.actorName ?? "")
+ let tokenRef = try await keychain.store(authResponse.token, attributes)
+
+ try await tunnelStore.setAuthStatus(.signedIn(authBaseURL: self.authBaseURL, accountId: accountId, tokenReference: tokenRef))
}
func signIn() async throws {
logger.trace("\(#function)")
- guard let teamId = settingsClient.fetchSettings()?.teamId, !teamId.isEmpty else {
- logger.debug("No team-id found in settings")
+ guard case .signedOut(let accountId) = self.loginStatus, let accountId = accountId, !accountId.isEmpty else {
+ logger.debug("No account-id found in tunnel")
throw FirezoneError.missingTeamId
}
- try await signIn(teamId: teamId)
+ try await signIn(accountId: accountId)
}
- func signOut() {
+ func signOut() async throws {
logger.trace("\(#function)")
- loginStatus = .signedOut
+ guard case .signedIn = self.loginStatus else {
+ return
+ }
+
+ Task {
+ if let tokenRef = try await tunnelStore.stopAndSignOut() {
+ try await keychain.delete(tokenRef)
+ }
+ }
}
- static func getAuthBaseURLFromInfoPlist() -> URL {
- let infoPlistDictionary = Bundle.main.infoDictionary
- guard let urlScheme = (infoPlistDictionary?["AuthURLScheme"] as? String), !urlScheme.isEmpty else {
- fatalError("AuthURLScheme missing in Info.plist. Please define AUTH_URL_SCHEME, AUTH_URL_HOST, CONTROL_PLANE_URL_SCHEME, and CONTROL_PLANE_URL_HOST in Server.xcconfig.")
+ func tunnelAuthStatusForAccount(accountId: String) async -> TunnelAuthStatus {
+ let portalURL = authURL(accountId: accountId)
+ if let tokenRef = await keychain.searchByAuthURL(portalURL) {
+ return .signedIn(authBaseURL: authBaseURL, accountId: accountId, tokenReference: tokenRef)
+ } else {
+ return .signedOut(authBaseURL: authBaseURL, accountId: accountId)
}
- guard let urlHost = (infoPlistDictionary?["AuthURLHost"] as? String), !urlHost.isEmpty else {
- fatalError("AuthURLHost missing in Info.plist. Please define AUTH_URL_SCHEME, AUTH_URL_HOST, CONTROL_PLANE_URL_SCHEME, and CONTROL_PLANE_URL_HOST in Server.xcconfig.")
- }
- let urlString = "\(urlScheme)://\(urlHost)/"
- guard let url = URL(string: urlString) else {
- fatalError("Cannot form valid URL from string: \(urlString)")
- }
- return url
}
- func authURL(teamId: String) -> URL {
- self.authBaseURL.appendingPathComponent(teamId)
+ func authURL(accountId: String) -> URL {
+ self.authBaseURL.appendingPathComponent(accountId)
}
}
diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/TunnelStore.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/TunnelStore.swift
index 6286f3f56..930c1ce9e 100644
--- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/TunnelStore.swift
+++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/TunnelStore.swift
@@ -16,74 +16,91 @@ enum TunnelStoreError: Error {
final class TunnelStore: ObservableObject {
private static let logger = Logger.make(for: TunnelStore.self)
- var tunnel: NETunnelProviderManager {
- didSet { setupTunnelObservers() }
- }
+ static let shared = TunnelStore()
+
+ static let keyAuthBaseURLString = "authBaseURLString"
+ static let keyAccountId = "accountId"
+
+ @Published private var tunnel: NETunnelProviderManager?
+ @Published private(set) var tunnelAuthStatus: TunnelAuthStatus = TunnelAuthStatus(protocolConfiguration: nil)
@Published private(set) var status: NEVPNStatus {
didSet { TunnelStore.logger.info("status changed: \(self.status.description)") }
}
- @Published private(set) var isEnabled = false {
- didSet { TunnelStore.logger.info("isEnabled changed: \(self.isEnabled.description)") }
- }
-
@Published private(set) var resources = DisplayableResources()
private var resourcesTimer: Timer? {
didSet(oldValue) { oldValue?.invalidate() }
}
- private let controlPlaneURL: URL
private var tunnelObservingTasks: [Task] = []
private var startTunnelContinuation: CheckedContinuation<(), Error>?
+ private var cancellables = Set()
- init(tunnel: NETunnelProviderManager) {
- self.controlPlaneURL = Self.getControlPlaneURLFromInfoPlist()
- self.tunnel = tunnel
- self.status = tunnel.connection.status
- tunnel.isEnabled = true
- setupTunnelObservers()
+ init() {
+ self.tunnel = nil
+ self.tunnelAuthStatus = TunnelAuthStatus(protocolConfiguration: nil)
+ self.status = .invalid
+
+ Task {
+ await initializeTunnel()
+ }
}
- static func loadOrCreate() async throws -> NETunnelProviderManager {
- logger.trace("\(#function)")
+ func initializeTunnel() async {
+ do {
+ let managers = try await NETunnelProviderManager.loadAllFromPreferences()
+ Self.logger.log("\(#function): \(managers.count) tunnel managers found")
+ if let tunnel = managers.first {
+ Self.logger.log("\(#function): Tunnel already exists")
+ self.tunnel = tunnel
+ self.tunnelAuthStatus = TunnelAuthStatus(protocolConfiguration: tunnel.protocolConfiguration as? NETunnelProviderProtocol)
+ } else {
+ let tunnel = NETunnelProviderManager()
+ tunnel.localizedDescription = "Firezone"
+ tunnel.protocolConfiguration = TunnelAuthStatus.accountNotSetup.toProtocolConfiguration()
+ try await tunnel.saveToPreferences()
+ Self.logger.log("\(#function): Tunnel created")
+ self.tunnel = tunnel
+ self.tunnelAuthStatus = .accountNotSetup
+ }
+ setupTunnelObservers()
+ Self.logger.log("\(#function): TunnelStore initialized")
+ } catch {
+ Self.logger.error("Error (\(#function)): \(error)")
+ }
+ }
- let managers = try await NETunnelProviderManager.loadAllFromPreferences()
-
- if let tunnel = managers.first {
- return tunnel
+ func setAuthStatus(_ tunnelAuthStatus: TunnelAuthStatus) async throws {
+ guard let tunnel = tunnel else {
+ fatalError("Tunnel not initialized yet")
}
- let tunnel = makeManager()
+ let wasConnected = (tunnel.connection.status == .connected || tunnel.connection.status == .connecting)
+ if wasConnected {
+ stop()
+ }
+ tunnel.protocolConfiguration = tunnelAuthStatus.toProtocolConfiguration()
try await tunnel.saveToPreferences()
- try await tunnel.loadFromPreferences()
-
- return tunnel
+ self.tunnelAuthStatus = tunnelAuthStatus
}
- func start(authResponse: AuthResponse) async throws {
+ func start() async throws {
+ guard let tunnel = tunnel else {
+ Self.logger.log("\(#function): TunnelStore is not initialized")
+ return
+ }
+
TunnelStore.logger.trace("\(#function)")
- // make sure we have latest preferences before starting
- try await tunnel.loadFromPreferences()
-
if tunnel.connection.status == .connected || tunnel.connection.status == .connecting {
- if let (tunnelControlPlaneURLString, tunnelToken) = Self.getTunnelConfigurationParameters(of: tunnel) {
- if tunnelControlPlaneURLString == self.controlPlaneURL.absoluteString && tunnelToken == authResponse.token {
- // Already connected / connecting with the required configuration
- TunnelStore.logger.debug("\(#function): Already connected / connecting. Nothing to do.")
- return
- }
- }
+ return
}
- tunnel.protocolConfiguration = Self.makeProtocolConfiguration(
- controlPlaneURL: self.controlPlaneURL,
- token: authResponse.token
- )
tunnel.isEnabled = true
try await tunnel.saveToPreferences()
+ try await tunnel.loadFromPreferences()
let session = tunnel.connection as! NETunnelProviderSession
try session.startTunnel()
@@ -93,11 +110,34 @@ final class TunnelStore: ObservableObject {
}
func stop() {
+ guard let tunnel = tunnel else {
+ Self.logger.log("\(#function): TunnelStore is not initialized")
+ return
+ }
+
TunnelStore.logger.trace("\(#function)")
let session = tunnel.connection as! NETunnelProviderSession
session.stopTunnel()
}
+ func stopAndSignOut() async throws -> Keychain.PersistentRef? {
+ guard let tunnel = tunnel else {
+ Self.logger.log("\(#function): TunnelStore is not initialized")
+ return nil
+ }
+
+ TunnelStore.logger.trace("\(#function)")
+ let session = tunnel.connection as! NETunnelProviderSession
+ session.stopTunnel()
+
+ if case .signedIn(let authBaseURL, let accountId, let tokenReference) = self.tunnelAuthStatus {
+ try await setAuthStatus(.signedOut(authBaseURL: authBaseURL, accountId: accountId))
+ return tokenReference
+ }
+
+ return nil
+ }
+
func beginUpdatingResources() {
self.updateResources()
let timer = Timer(timeInterval: 1 /*second*/, repeats: true) { [weak self] _ in
@@ -114,6 +154,11 @@ final class TunnelStore: ObservableObject {
}
private func updateResources() {
+ guard let tunnel = tunnel else {
+ Self.logger.log("\(#function): TunnelStore is not initialized")
+ return
+ }
+
let session = tunnel.connection as! NETunnelProviderSession
guard session.status == .connected else {
self.resources = DisplayableResources()
@@ -139,57 +184,9 @@ final class TunnelStore: ObservableObject {
let manager = NETunnelProviderManager()
manager.localizedDescription = "Firezone"
- let proto = makeProtocolConfiguration()
- manager.protocolConfiguration = proto
- manager.isEnabled = true
-
return manager
}
- static func getControlPlaneURLFromInfoPlist() -> URL {
- let infoPlistDictionary = Bundle.main.infoDictionary
- guard let urlScheme = (infoPlistDictionary?["ControlPlaneURLScheme"] as? String), !urlScheme.isEmpty else {
- fatalError("AuthURLScheme missing in Info.plist. Please define AUTH_URL_SCHEME, AUTH_URL_HOST, CONTROL_PLANE_URL_SCHEME, and CONTROL_PLANE_URL_HOST in Server.xcconfig.")
- }
- guard let urlHost = (infoPlistDictionary?["ControlPlaneURLHost"] as? String), !urlHost.isEmpty else {
- fatalError("AuthURLHost missing in Info.plist. Please define AUTH_URL_SCHEME, AUTH_URL_HOST, CONTROL_PLANE_URL_SCHEME, and CONTROL_PLANE_URL_HOST in Server.xcconfig.")
- }
- let urlString = "\(urlScheme)://\(urlHost)/"
- guard let url = URL(string: urlString) else {
- fatalError("Cannot form valid URL from string: \(urlString)")
- }
- return url
- }
-
- private static func makeProtocolConfiguration(controlPlaneURL: URL? = nil, token: String? = nil) -> NETunnelProviderProtocol {
- let proto = NETunnelProviderProtocol()
-
- proto.providerBundleIdentifier = Bundle.main.bundleIdentifier.map {
- "\($0).network-extension"
- }
- if let controlPlaneURL = controlPlaneURL, let token = token {
- proto.providerConfiguration = [
- "controlPlaneURL": controlPlaneURL.absoluteString,
- "token": token
- ]
- }
- proto.serverAddress = "Firezone addresses"
- return proto
- }
-
- private static func getTunnelConfigurationParameters(of tunnelProvider: NETunnelProviderManager) -> (String, String)? {
- guard let tunnelProtocol = tunnelProvider.protocolConfiguration as? NETunnelProviderProtocol else {
- return nil
- }
- guard let controlPlaneURLString = tunnelProtocol.providerConfiguration?["controlPlaneURL"] as? String else {
- return nil
- }
- guard let token = tunnelProtocol.providerConfiguration?["token"] as? String else {
- return nil
- }
- return (controlPlaneURLString, token)
- }
-
private func setupTunnelObservers() {
TunnelStore.logger.trace("\(#function)")
@@ -229,11 +226,89 @@ final class TunnelStore: ObservableObject {
func removeProfile() async throws {
TunnelStore.logger.trace("\(#function)")
+ guard let tunnel = tunnel else {
+ Self.logger.log("\(#function): TunnelStore is not initialized")
+ return
+ }
try await tunnel.removeFromPreferences()
}
}
+enum TunnelAuthStatus {
+ case tunnelUninitialized
+ case accountNotSetup
+ case signedOut(authBaseURL: URL, accountId: String)
+ case signedIn(authBaseURL: URL, accountId: String, tokenReference: Data)
+
+ var isInitialized: Bool {
+ switch self {
+ case .tunnelUninitialized: return false
+ default: return true
+ }
+ }
+
+ init(protocolConfiguration: NETunnelProviderProtocol?) {
+ if let protocolConfiguration = protocolConfiguration {
+ let providerConfig = protocolConfiguration.providerConfiguration
+ let authBaseURL: URL? = {
+ guard let urlString = providerConfig?[TunnelStore.keyAuthBaseURLString] as? String else { return nil }
+ return URL(string: urlString)
+ }()
+ let accountId = providerConfig?[TunnelStore.keyAccountId] as? String
+ let tokenRef = protocolConfiguration.passwordReference
+ if let authBaseURL = authBaseURL, let accountId = accountId {
+ if let tokenRef = tokenRef {
+ self = .signedIn(authBaseURL: authBaseURL, accountId: accountId, tokenReference: tokenRef)
+ } else {
+ self = .signedOut(authBaseURL: authBaseURL, accountId: accountId)
+ }
+ } else {
+ self = .accountNotSetup
+ }
+ } else {
+ self = .tunnelUninitialized
+ }
+ }
+
+ func toProtocolConfiguration() -> NETunnelProviderProtocol {
+ let protocolConfiguration = NETunnelProviderProtocol()
+ protocolConfiguration.providerBundleIdentifier = Bundle.main.bundleIdentifier.map {
+ "\($0).network-extension"
+ }
+ protocolConfiguration.serverAddress = AppInfoPlistConstants.controlPlaneURL.absoluteString
+
+ switch self {
+ case .tunnelUninitialized, .accountNotSetup:
+ break
+ case .signedOut(let authBaseURL, let accountId):
+ protocolConfiguration.providerConfiguration = [
+ TunnelStore.keyAuthBaseURLString: authBaseURL.absoluteString,
+ TunnelStore.keyAccountId: accountId
+ ]
+ case .signedIn(let authBaseURL, let accountId, let tokenReference):
+ protocolConfiguration.providerConfiguration = [
+ TunnelStore.keyAuthBaseURLString: authBaseURL.absoluteString,
+ TunnelStore.keyAccountId: accountId
+ ]
+ protocolConfiguration.passwordReference = tokenReference
+ }
+
+ return protocolConfiguration
+ }
+
+ func accountId() -> String? {
+ switch self {
+ case .tunnelUninitialized, .accountNotSetup:
+ return nil
+ case .signedOut(_, let accountId):
+ return accountId
+ case .signedIn(_, let accountId, _):
+ return accountId
+ }
+ }
+}
+
// MARK: - Extensions
/// Make NEVPNStatus convertible to a string
diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift
index 340be8e41..5e4631a2e 100644
--- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift
+++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift
@@ -43,7 +43,7 @@ public final class MenuBar: NSObject {
private var connectingAnimationTimer: Timer?
let settingsViewModel: SettingsViewModel
- private var loginStatus: AuthStore.LoginStatus = .signedOut
+ private var loginStatus: AuthStore.LoginStatus = .signedOut(accountId: nil)
private var tunnelStatus: NEVPNStatus = .invalid
@@ -64,8 +64,7 @@ public final class MenuBar: NSObject {
}
Task {
- let tunnel = try await TunnelStore.loadOrCreate()
- self.appStore = AppStore(tunnelStore: TunnelStore(tunnel: tunnel))
+ self.appStore = AppStore(tunnelStore: TunnelStore.shared)
updateStatusItemIcon()
}
}
@@ -206,12 +205,11 @@ public final class MenuBar: NSObject {
@objc private func reconnectButtonTapped() {
Task {
- if case .signedIn(let authResponse) = appStore?.auth.loginStatus {
+ if case .signedIn = appStore?.auth.loginStatus {
do {
- try await appStore?.tunnel.start(authResponse: authResponse)
+ try await appStore?.tunnel.start()
} catch {
- logger.error("error connecting to tunnel: \(String(describing: error)) -- signing out")
- appStore?.auth.signOut()
+ logger.error("error connecting to tunnel (reconnect): \(String(describing: error))")
}
}
}
@@ -230,7 +228,13 @@ public final class MenuBar: NSObject {
}
@objc private func signOutButtonTapped() {
- appStore?.auth.signOut()
+ Task {
+ do {
+ try await appStore?.auth.signOut()
+ } catch {
+ logger.error("error signing out: \(String(describing: error))")
+ }
+ }
}
@objc private func settingsButtonTapped() {
@@ -306,13 +310,8 @@ public final class MenuBar: NSObject {
signInMenuItem.title = "Sign In"
signInMenuItem.target = self
signOutMenuItem.isHidden = true
- case .signedIn(let authResponse):
- signInMenuItem.title = {
- guard let actorName = authResponse.actorName else {
- return "Signed in"
- }
- return "Signed in as \(actorName)"
- }()
+ case .signedIn(_, let actorName):
+ signInMenuItem.title = actorName.isEmpty ? "Signed in" : "Signed in as \(actorName)"
signInMenuItem.target = nil
signOutMenuItem.isHidden = false
}
diff --git a/swift/apple/FirezoneNetworkExtension/FirezoneNetworkExtension_iOS.entitlements b/swift/apple/FirezoneNetworkExtension/FirezoneNetworkExtension_iOS.entitlements
index ffab33e01..aa8672e95 100644
--- a/swift/apple/FirezoneNetworkExtension/FirezoneNetworkExtension_iOS.entitlements
+++ b/swift/apple/FirezoneNetworkExtension/FirezoneNetworkExtension_iOS.entitlements
@@ -6,5 +6,9 @@
packet-tunnel-provider
+ com.apple.security.application-groups
+
+ ${APP_GROUP_ID}
+
diff --git a/swift/apple/FirezoneNetworkExtension/FirezoneNetworkExtension_macOS.entitlements b/swift/apple/FirezoneNetworkExtension/FirezoneNetworkExtension_macOS.entitlements
index dbbd02597..7546e62e5 100644
--- a/swift/apple/FirezoneNetworkExtension/FirezoneNetworkExtension_macOS.entitlements
+++ b/swift/apple/FirezoneNetworkExtension/FirezoneNetworkExtension_macOS.entitlements
@@ -6,6 +6,10 @@
packet-tunnel-provider
+ com.apple.security.application-groups
+
+ ${APP_GROUP_ID}
+
com.apple.security.app-sandbox
com.apple.security.network.client
diff --git a/swift/apple/FirezoneNetworkExtension/Info.plist b/swift/apple/FirezoneNetworkExtension/Info.plist
index 3059459e1..2ea50fd66 100644
--- a/swift/apple/FirezoneNetworkExtension/Info.plist
+++ b/swift/apple/FirezoneNetworkExtension/Info.plist
@@ -9,5 +9,7 @@
NSExtensionPrincipalClass
$(PRODUCT_MODULE_NAME).PacketTunnelProvider
+ AppGroupIdentifier
+ ${APP_GROUP_ID}
diff --git a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift
index a868c422c..a33909397 100644
--- a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift
+++ b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift
@@ -7,9 +7,11 @@
import Dependencies
import NetworkExtension
import os
+import FirezoneKit
-enum PacketTunnelProviderError: String, Error {
- case savedProtocolConfigurationIsInvalid
+enum PacketTunnelProviderError: Error {
+ case savedProtocolConfigurationIsInvalid(String)
+ case tokenNotFoundInKeychain
case couldNotSetNetworkSettings
}
@@ -23,31 +25,38 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
completionHandler: @escaping (Error?) -> Void
) {
Self.logger.trace("\(#function)")
- guard let tunnelProviderProtocol = self.protocolConfiguration as? NETunnelProviderProtocol else {
- completionHandler(PacketTunnelProviderError.savedProtocolConfigurationIsInvalid)
+
+ guard let controlPlaneURLString = protocolConfiguration.serverAddress else {
+ Self.logger.error("serverAddress is missing")
+ completionHandler(PacketTunnelProviderError.savedProtocolConfigurationIsInvalid("serverAddress"))
return
}
- let providerConfiguration = tunnelProviderProtocol.providerConfiguration
- guard let controlPlaneURLString = providerConfiguration?["controlPlaneURL"] as? String else {
- completionHandler(PacketTunnelProviderError.savedProtocolConfigurationIsInvalid)
+ guard let tokenRef = protocolConfiguration.passwordReference else {
+ Self.logger.error("passwordReference is missing")
+ completionHandler(PacketTunnelProviderError.savedProtocolConfigurationIsInvalid("passwordReference"))
return
}
- guard let token = providerConfiguration?["token"] as? String else {
- completionHandler(PacketTunnelProviderError.savedProtocolConfigurationIsInvalid)
- return
- }
- let adapter = Adapter(controlPlaneURLString: controlPlaneURLString, token: token, packetTunnelProvider: self)
- self.adapter = adapter
- do {
- try adapter.start() { error in
- if let error {
- Self.logger.error("Error in adapter.start: \(error)")
+
+ Task {
+ let keychain = Keychain()
+ guard let token = await keychain.load(persistentRef: tokenRef) else {
+ completionHandler(PacketTunnelProviderError.tokenNotFoundInKeychain)
+ return
+ }
+
+ let adapter = Adapter(controlPlaneURLString: controlPlaneURLString, token: token, packetTunnelProvider: self)
+ self.adapter = adapter
+ do {
+ try adapter.start() { error in
+ if let error {
+ Self.logger.error("Error in adapter.start: \(error)")
+ }
+ completionHandler(error)
}
+ } catch {
completionHandler(error)
}
- } catch {
- completionHandler(error)
}
}