Skip to content

Commit

Permalink
🆙 FIXUP: Save Spotify access token to keychain 🔐
Browse files Browse the repository at this point in the history
  • Loading branch information
superturboryan committed Nov 1, 2024
1 parent cf4f08c commit 0260755
Show file tree
Hide file tree
Showing 2 changed files with 120 additions and 1 deletion.
118 changes: 118 additions & 0 deletions Sources/Control/Keychain.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
//
// Keychain.swift
// ControlKit
//

import OSLog
import Security

public extension Control {

/// Wrapper for device keychain aka ``Security/SecItem`` 🔐
@propertyWrapper
public struct Keychain<T: Codable> {

private let key: String
private let defaultValue: T

public init(_ key: String, default: T) {
self.key = key
self.defaultValue = `default`
}

public var wrappedValue: T {
get {
KeychainHelper.retrieveValue(for: key, as: T.self) ?? defaultValue
}
set {
KeychainHelper.save(newValue, for: key)
}
}
}
}

extension Control {

public enum KeychainHelper {

/// Save a value to the keychain.
public static func save<T: Codable>(_ value: T, for key: String) {
do {
let data = try encoder.encode(value)

// Delete any existing item before adding a new one
deleteValue(for: key)

let attributes = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: key,
kSecValueData: data,
kSecAttrAccessible: kSecAttrAccessibleWhenUnlocked
] as CFDictionary
SecItemAdd(attributes, nil)
} catch {
log.error("Failed to save value: \(error.localizedDescription)")
}
}

/// Retrieve a value from the keychain.
public static func retrieveValue<T: Codable>(for key: String, as type: T.Type) -> T? {
let query = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: key,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne,
kSecAttrAccessible: kSecAttrAccessibleWhenUnlocked
] as CFDictionary

var result: AnyObject?
let status = SecItemCopyMatching(query, &result)

do {
guard
status == errSecSuccess,
let data = result as? Data
else {
throw Error.secItemCopyMatching(status)
}
return try decoder.decode(T.self, from: data)
} catch {
log.error("Failed to retrieve value: \(error.localizedDescription)")
return nil
}
}

/// Delete a value from the keychain.
public static func deleteValue(for key: String) {
let query = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: key,
kSecAttrAccessible: kSecAttrAccessibleWhenUnlocked
] as CFDictionary
SecItemDelete(query)
}
}
}

private extension Control.KeychainHelper {

static let decoder = JSONDecoder()
static let encoder = JSONEncoder()
static let log = Logger(
subsystem: Control.subsystem,
category: "Keychain"
)

enum Error: LocalizedError {

case secItemCopyMatching(_ status: OSStatus)

var errorDescription: String? {
switch self {

case .secItemCopyMatching(let status):
"SecItemCopyMatching failed with status: \(status)"
}
}
}
}
3 changes: 2 additions & 1 deletion Sources/Controllers/SpotifyController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// ControlKit
//

import Control
import OSLog
import SpotifyiOS
import SwiftUI
Expand All @@ -26,7 +27,7 @@ public final class SpotifyController: NSObject, ObservableObject {
return remote
}()

@AppStorage("SpotifyAccessToken")
@Control.Keychain("SpotifyAccessToken", default: nil)
private var accessToken: String?

override public init() {
Expand Down

0 comments on commit 0260755

Please sign in to comment.