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:
Roopesh Chander
2023-09-22 16:56:28 +05:30
committed by GitHub
parent c6ec7ab2db
commit d6a3d06b0c
26 changed files with 647 additions and 388 deletions

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -1,2 +1,2 @@
DEVELOPMENT_TEAM = 0000000000
PRODUCT_BUNDLE_IDENTIFIER = dev.firezone.ios
APP_ID = dev.firezone.ios

View File

@@ -1,2 +1,2 @@
DEVELOPMENT_TEAM = 0000000000
PRODUCT_BUNDLE_IDENTIFIER = dev.firezone.macos
APP_ID = dev.firezone.macos

View File

@@ -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>

View File

@@ -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
)
)
}

View File

@@ -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)
}

View File

@@ -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
)
)
)

View File

@@ -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 }
)
)
}

View File

@@ -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))
)
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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
}
)
}

View File

@@ -7,5 +7,5 @@
import Foundation
struct Settings: Codable, Hashable {
var teamId: String = ""
var accountId: String = ""
}

View File

@@ -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 }
}
}

View File

@@ -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()
}
}
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -9,5 +9,7 @@
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).PacketTunnelProvider</string>
</dict>
<key>AppGroupIdentifier</key>
<string>${APP_GROUP_ID}</string>
</dict>
</plist>

View File

@@ -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)
}
}