mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
apple: Use the tunnel configuration for data persistence (#2113)
Fixes #2048. Fixes #2023. Previously: - accountId was stored in UserDefaults - token and actorName were stored as two separate items of data in the keychain (only one token+actorName can be in storage at any point in time) With this PR: - accountId is stored in the tunnel configuration, along with the authBaseURL - token is stored in the keychain, along with the authURL and actorName as attributes on the same keychain item - a persistent reference to the token is stored in the tunnel configuration (only the app and the tunnel can dereference it to access the token without user intervention) - once stored, the app never reads the token; the tunnel reads the token directly from the keychain - token is stored per authURL; so two tokens for two different authURLs can be in storage at the same time - when the accountId is changed in app settings, the app searches for the new authURL in the keychain, and if it finds an item, considers the app to be logged in with that user (a proper UI for switching accounts shall come in later)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
<array>
|
||||
<string>packet-tunnel-provider</string>
|
||||
</array>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>${APP_GROUP_ID}</string>
|
||||
</array>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>AppGroupIdentifier</key>
|
||||
<string>${APP_GROUP_ID}</string>
|
||||
<key>AuthURLScheme</key>
|
||||
<string>$(AUTH_URL_SCHEME)</string>
|
||||
<key>AuthURLHost</key>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
DEVELOPMENT_TEAM = 0000000000
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.firezone.ios
|
||||
APP_ID = dev.firezone.ios
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
DEVELOPMENT_TEAM = 0000000000
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.firezone.macos
|
||||
APP_ID = dev.firezone.macos
|
||||
|
||||
@@ -4,4 +4,4 @@ DEVELOPMENT_TEAM = <team_id>
|
||||
// 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>
|
||||
APP_ID = <app_id>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<AnyCancellable>()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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<AnyCancellable>()
|
||||
|
||||
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 }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<AnyCancellable>()
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
import Foundation
|
||||
|
||||
struct Settings: Codable, Hashable {
|
||||
var teamId: String = ""
|
||||
var accountId: String = ""
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AnyCancellable>()
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Void, Never>] = []
|
||||
private var startTunnelContinuation: CheckedContinuation<(), Error>?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -6,5 +6,9 @@
|
||||
<array>
|
||||
<string>packet-tunnel-provider</string>
|
||||
</array>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>${APP_GROUP_ID}</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
<array>
|
||||
<string>packet-tunnel-provider</string>
|
||||
</array>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>${APP_GROUP_ID}</string>
|
||||
</array>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
|
||||
@@ -9,5 +9,7 @@
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).PacketTunnelProvider</string>
|
||||
</dict>
|
||||
<key>AppGroupIdentifier</key>
|
||||
<string>${APP_GROUP_ID}</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user