From 336a50ed6c9e803a45d150dd6ca31c0a9ebadd9a Mon Sep 17 00:00:00 2001 From: nimbold <11913706+nimbold@users.noreply.github.com> Date: Tue, 9 Jun 2026 06:48:06 +0330 Subject: [PATCH] fix(integration): resolve CORS preflight bug and secure pairing token storage - Allow GET method in LocalExtensionServer CORS preflight response - Migrate pairing token storage from UserDefaults to KeychainCredentialStore - Upgrade token generation to use SecRandomCopyBytes - Update IntegrationSettingsPane UI to be browser-agnostic with a Regenerate token action --- Sources/Firelink/AppSettings.swift | 26 ++++-- .../Firelink/KeychainCredentialStore.swift | 47 ++++++++++ Sources/Firelink/LocalExtensionServer.swift | 2 +- .../Settings/IntegrationSettingsPane.swift | 87 +++++++++++++------ 4 files changed, 129 insertions(+), 33 deletions(-) diff --git a/Sources/Firelink/AppSettings.swift b/Sources/Firelink/AppSettings.swift index 7a18849..d6a5d33 100644 --- a/Sources/Firelink/AppSettings.swift +++ b/Sources/Firelink/AppSettings.swift @@ -184,7 +184,9 @@ final class AppSettings: ObservableObject { } @Published var extensionPairingToken: String { - didSet { save() } + didSet { + KeychainCredentialStore.setExtensionToken(extensionPairingToken) + } } @Published var message = "" @@ -208,7 +210,6 @@ final class AppSettings: ObservableObject { proxySettings = stored.proxySettings?.normalized ?? ProxySettings() siteLogins = stored.siteLogins mediaCookieSource = stored.mediaCookieSource ?? .none - extensionPairingToken = stored.extensionPairingToken ?? UUID().uuidString downloadDirectories = Self.decodeDirectories(stored.downloadDirectories) } else { appTheme = .system @@ -221,10 +222,16 @@ final class AppSettings: ObservableObject { proxySettings = ProxySettings() siteLogins = [] mediaCookieSource = .none - extensionPairingToken = UUID().uuidString downloadDirectories = Self.defaultDirectories() } + if let token = KeychainCredentialStore.extensionToken() { + extensionPairingToken = token + } else { + extensionPairingToken = Self.generateSecureToken() + KeychainCredentialStore.setExtensionToken(extensionPairingToken) + } + for category in DownloadCategory.allCases where downloadDirectories[category] == nil { downloadDirectories[category] = Self.defaultDirectory(for: category).path } @@ -338,8 +345,7 @@ final class AppSettings: ObservableObject { proxySettings: proxySettings.normalized, downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }), siteLogins: siteLogins, - mediaCookieSource: mediaCookieSource, - extensionPairingToken: extensionPairingToken + mediaCookieSource: mediaCookieSource ) let defaults = self.defaults let storageKey = self.storageKey @@ -381,6 +387,15 @@ final class AppSettings: ObservableObject { return host == normalizedPattern } + private static func generateSecureToken() -> String { + var bytes = [UInt8](repeating: 0, count: 32) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + return UUID().uuidString + } + return Data(bytes).base64EncodedString() + } + private static func defaultDirectories() -> [DownloadCategory: String] { Dictionary(uniqueKeysWithValues: DownloadCategory.allCases.map { ($0, defaultDirectory(for: $0).path) }) } @@ -411,5 +426,4 @@ private struct StoredSettings: Codable { var downloadDirectories: [String: String] var siteLogins: [SiteLogin] var mediaCookieSource: BrowserCookieSource? - var extensionPairingToken: String? } diff --git a/Sources/Firelink/KeychainCredentialStore.swift b/Sources/Firelink/KeychainCredentialStore.swift index 3f07361..32d48af 100644 --- a/Sources/Firelink/KeychainCredentialStore.swift +++ b/Sources/Firelink/KeychainCredentialStore.swift @@ -45,6 +45,53 @@ enum KeychainCredentialStore { kSecAttrAccount as String: id.uuidString ] + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } + private static let extensionTokenService = "local.firelink.extension-token" + private static let extensionTokenAccount = "pairing-token" + + static func extensionToken() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: extensionTokenService, + kSecAttrAccount as String: extensionTokenAccount, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data else { + return nil + } + + return String(data: data, encoding: .utf8) + } + + @discardableResult + static func setExtensionToken(_ token: String) -> Bool { + deleteExtensionToken() + + let attributes: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: extensionTokenService, + kSecAttrAccount as String: extensionTokenAccount, + kSecValueData as String: Data(token.utf8), + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly + ] + + return SecItemAdd(attributes as CFDictionary, nil) == errSecSuccess + } + + @discardableResult + static func deleteExtensionToken() -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: extensionTokenService, + kSecAttrAccount as String: extensionTokenAccount + ] + let status = SecItemDelete(query as CFDictionary) return status == errSecSuccess || status == errSecItemNotFound } diff --git a/Sources/Firelink/LocalExtensionServer.swift b/Sources/Firelink/LocalExtensionServer.swift index af74f57..20217f7 100644 --- a/Sources/Firelink/LocalExtensionServer.swift +++ b/Sources/Firelink/LocalExtensionServer.swift @@ -108,7 +108,7 @@ final class LocalExtensionServer: @unchecked Sendable { if let origin, isAllowedExtensionOrigin(origin) { headers.append("Access-Control-Allow-Origin: \(origin)") headers.append("Vary: Origin") - headers.append("Access-Control-Allow-Methods: POST, OPTIONS") + headers.append("Access-Control-Allow-Methods: GET, POST, OPTIONS") headers.append("Access-Control-Allow-Headers: Content-Type, X-Firelink-Extension") } diff --git a/Sources/Firelink/Settings/IntegrationSettingsPane.swift b/Sources/Firelink/Settings/IntegrationSettingsPane.swift index dfd1f4a..7fac531 100644 --- a/Sources/Firelink/Settings/IntegrationSettingsPane.swift +++ b/Sources/Firelink/Settings/IntegrationSettingsPane.swift @@ -36,28 +36,42 @@ struct IntegrationSettingsPane: View { description: "This secure token authorizes your browser extension.", icon: "doc.on.clipboard.fill", iconColor: .blue, - actionText: "Copy Token" - ) { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(settings.extensionPairingToken, forType: .string) - withAnimation { - showToast = true + actionText: "Copy Token", + action: { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(settings.extensionPairingToken, forType: .string) + withAnimation { + showToast = true + } + }, + secondaryActionText: "Regenerate", + secondaryAction: { + var bytes = [UInt8](repeating: 0, count: 32) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + settings.extensionPairingToken = status == errSecSuccess ? Data(bytes).base64EncodedString() : UUID().uuidString } - } + ) - // Step 2: Open Browser + // Step 2: Get Extension StepCardView( stepNumber: 2, - title: "Open Firefox", - description: "Launch your browser. If you haven't installed the extension yet, do that first.", - icon: "safari.fill", + title: "Get Extension", + description: "Install the Firelink Companion extension on your favorite browser.", + icon: "globe", iconColor: .orange, - actionText: "Open Firefox" - ) { - if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") { - NSWorkspace.shared.open(url) + actionText: "Firefox Add-ons", + action: { + if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") { + NSWorkspace.shared.open(url) + } + }, + secondaryActionText: "Releases", + secondaryAction: { + if let url = URL(string: "https://github.com/nimbold/Firelink-Extension/releases") { + NSWorkspace.shared.open(url) + } } - } + ) // Step 3: Paste and Save StepCardView( @@ -104,6 +118,8 @@ struct StepCardView: View { let iconColor: Color let actionText: String? let action: (() -> Void)? + var secondaryActionText: String? = nil + var secondaryAction: (() -> Void)? = nil var body: some View { HStack(spacing: 16) { @@ -143,17 +159,36 @@ struct StepCardView: View { Spacer() // Action Button - if let actionText = actionText, let action = action { - Button(action: action) { - Text(actionText) - .font(.subheadline.weight(.medium)) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(Color.accentColor) - .foregroundColor(.white) - .cornerRadius(8) + HStack(spacing: 8) { + if let secondaryActionText = secondaryActionText, let secondaryAction = secondaryAction { + Button(action: secondaryAction) { + Text(secondaryActionText) + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color(nsColor: .controlBackgroundColor)) + .foregroundColor(.primary) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + if let actionText = actionText, let action = action { + Button(action: action) { + Text(actionText) + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.accentColor) + .foregroundColor(.white) + .cornerRadius(8) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } } .padding(16)