mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-30 12:29:34 +00:00
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
This commit is contained in:
@@ -184,7 +184,9 @@ final class AppSettings: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Published var extensionPairingToken: String {
|
@Published var extensionPairingToken: String {
|
||||||
didSet { save() }
|
didSet {
|
||||||
|
KeychainCredentialStore.setExtensionToken(extensionPairingToken)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Published var message = ""
|
@Published var message = ""
|
||||||
@@ -208,7 +210,6 @@ final class AppSettings: ObservableObject {
|
|||||||
proxySettings = stored.proxySettings?.normalized ?? ProxySettings()
|
proxySettings = stored.proxySettings?.normalized ?? ProxySettings()
|
||||||
siteLogins = stored.siteLogins
|
siteLogins = stored.siteLogins
|
||||||
mediaCookieSource = stored.mediaCookieSource ?? .none
|
mediaCookieSource = stored.mediaCookieSource ?? .none
|
||||||
extensionPairingToken = stored.extensionPairingToken ?? UUID().uuidString
|
|
||||||
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
|
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
|
||||||
} else {
|
} else {
|
||||||
appTheme = .system
|
appTheme = .system
|
||||||
@@ -221,10 +222,16 @@ final class AppSettings: ObservableObject {
|
|||||||
proxySettings = ProxySettings()
|
proxySettings = ProxySettings()
|
||||||
siteLogins = []
|
siteLogins = []
|
||||||
mediaCookieSource = .none
|
mediaCookieSource = .none
|
||||||
extensionPairingToken = UUID().uuidString
|
|
||||||
downloadDirectories = Self.defaultDirectories()
|
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 {
|
for category in DownloadCategory.allCases where downloadDirectories[category] == nil {
|
||||||
downloadDirectories[category] = Self.defaultDirectory(for: category).path
|
downloadDirectories[category] = Self.defaultDirectory(for: category).path
|
||||||
}
|
}
|
||||||
@@ -338,8 +345,7 @@ final class AppSettings: ObservableObject {
|
|||||||
proxySettings: proxySettings.normalized,
|
proxySettings: proxySettings.normalized,
|
||||||
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
|
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
|
||||||
siteLogins: siteLogins,
|
siteLogins: siteLogins,
|
||||||
mediaCookieSource: mediaCookieSource,
|
mediaCookieSource: mediaCookieSource
|
||||||
extensionPairingToken: extensionPairingToken
|
|
||||||
)
|
)
|
||||||
let defaults = self.defaults
|
let defaults = self.defaults
|
||||||
let storageKey = self.storageKey
|
let storageKey = self.storageKey
|
||||||
@@ -381,6 +387,15 @@ final class AppSettings: ObservableObject {
|
|||||||
return host == normalizedPattern
|
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] {
|
private static func defaultDirectories() -> [DownloadCategory: String] {
|
||||||
Dictionary(uniqueKeysWithValues: DownloadCategory.allCases.map { ($0, defaultDirectory(for: $0).path) })
|
Dictionary(uniqueKeysWithValues: DownloadCategory.allCases.map { ($0, defaultDirectory(for: $0).path) })
|
||||||
}
|
}
|
||||||
@@ -411,5 +426,4 @@ private struct StoredSettings: Codable {
|
|||||||
var downloadDirectories: [String: String]
|
var downloadDirectories: [String: String]
|
||||||
var siteLogins: [SiteLogin]
|
var siteLogins: [SiteLogin]
|
||||||
var mediaCookieSource: BrowserCookieSource?
|
var mediaCookieSource: BrowserCookieSource?
|
||||||
var extensionPairingToken: String?
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,53 @@ enum KeychainCredentialStore {
|
|||||||
kSecAttrAccount as String: id.uuidString
|
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)
|
let status = SecItemDelete(query as CFDictionary)
|
||||||
return status == errSecSuccess || status == errSecItemNotFound
|
return status == errSecSuccess || status == errSecItemNotFound
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ final class LocalExtensionServer: @unchecked Sendable {
|
|||||||
if let origin, isAllowedExtensionOrigin(origin) {
|
if let origin, isAllowedExtensionOrigin(origin) {
|
||||||
headers.append("Access-Control-Allow-Origin: \(origin)")
|
headers.append("Access-Control-Allow-Origin: \(origin)")
|
||||||
headers.append("Vary: 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")
|
headers.append("Access-Control-Allow-Headers: Content-Type, X-Firelink-Extension")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,28 +36,42 @@ struct IntegrationSettingsPane: View {
|
|||||||
description: "This secure token authorizes your browser extension.",
|
description: "This secure token authorizes your browser extension.",
|
||||||
icon: "doc.on.clipboard.fill",
|
icon: "doc.on.clipboard.fill",
|
||||||
iconColor: .blue,
|
iconColor: .blue,
|
||||||
actionText: "Copy Token"
|
actionText: "Copy Token",
|
||||||
) {
|
action: {
|
||||||
NSPasteboard.general.clearContents()
|
NSPasteboard.general.clearContents()
|
||||||
NSPasteboard.general.setString(settings.extensionPairingToken, forType: .string)
|
NSPasteboard.general.setString(settings.extensionPairingToken, forType: .string)
|
||||||
withAnimation {
|
withAnimation {
|
||||||
showToast = true
|
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(
|
StepCardView(
|
||||||
stepNumber: 2,
|
stepNumber: 2,
|
||||||
title: "Open Firefox",
|
title: "Get Extension",
|
||||||
description: "Launch your browser. If you haven't installed the extension yet, do that first.",
|
description: "Install the Firelink Companion extension on your favorite browser.",
|
||||||
icon: "safari.fill",
|
icon: "globe",
|
||||||
iconColor: .orange,
|
iconColor: .orange,
|
||||||
actionText: "Open Firefox"
|
actionText: "Firefox Add-ons",
|
||||||
) {
|
action: {
|
||||||
if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") {
|
if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") {
|
||||||
NSWorkspace.shared.open(url)
|
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
|
// Step 3: Paste and Save
|
||||||
StepCardView(
|
StepCardView(
|
||||||
@@ -104,6 +118,8 @@ struct StepCardView: View {
|
|||||||
let iconColor: Color
|
let iconColor: Color
|
||||||
let actionText: String?
|
let actionText: String?
|
||||||
let action: (() -> Void)?
|
let action: (() -> Void)?
|
||||||
|
var secondaryActionText: String? = nil
|
||||||
|
var secondaryAction: (() -> Void)? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
@@ -143,17 +159,36 @@ struct StepCardView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Action Button
|
// Action Button
|
||||||
if let actionText = actionText, let action = action {
|
HStack(spacing: 8) {
|
||||||
Button(action: action) {
|
if let secondaryActionText = secondaryActionText, let secondaryAction = secondaryAction {
|
||||||
Text(actionText)
|
Button(action: secondaryAction) {
|
||||||
.font(.subheadline.weight(.medium))
|
Text(secondaryActionText)
|
||||||
.padding(.horizontal, 16)
|
.font(.subheadline.weight(.medium))
|
||||||
.padding(.vertical, 8)
|
.padding(.horizontal, 16)
|
||||||
.background(Color.accentColor)
|
.padding(.vertical, 8)
|
||||||
.foregroundColor(.white)
|
.background(Color(nsColor: .controlBackgroundColor))
|
||||||
.cornerRadius(8)
|
.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)
|
.padding(16)
|
||||||
|
|||||||
Reference in New Issue
Block a user