Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 74 additions & 13 deletions Spotifly/KeychainManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,16 @@ import Security

/// Manages secure storage of authentication tokens in the Keychain
enum KeychainManager {
private static let service = "com.spotifly.oauth"
// MARK: - Keychain Keys

private static let oauthService = "com.spotifly.oauth"
private static let configService = "com.spotifly.config"

private static let accessTokenKey = "spotify_access_token"
private static let refreshTokenKey = "spotify_refresh_token"
private static let expiresAtKey = "spotify_expires_at"
private static let customClientIdKey = "spotify_custom_client_id"
private static let useCustomClientIdKey = "spotify_use_custom_client_id"

// MARK: - Public API

Expand Down Expand Up @@ -78,9 +84,13 @@ enum KeychainManager {
let isExpired = result.expiresIn < 300 // 5 minutes

if isExpired, let refreshToken = result.refreshToken {
// Attempt to refresh the token
// Attempt to refresh the token using the stored auth mode
let useCustomClientId = loadUseCustomClientId()
do {
let newResult = try await SpotifyAuth.refreshAccessToken(refreshToken: refreshToken)
let newResult = try await SpotifyAuth.refreshAccessToken(
refreshToken: refreshToken,
useCustomClientId: useCustomClientId,
)

// Save the new result to keychain
try saveAuthResult(newResult)
Expand Down Expand Up @@ -114,13 +124,12 @@ enum KeychainManager {

/// Saves a custom Spotify Client ID to the keychain
nonisolated static func saveCustomClientId(_ clientId: String) throws {
// Delete any existing item first
clearCustomClientId()

let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.spotifly.config",
kSecAttrAccount as String: "spotify_custom_client_id",
kSecAttrService as String: configService,
kSecAttrAccount as String: customClientIdKey,
kSecValueData as String: clientId.data(using: .utf8)!,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
]
Expand All @@ -135,8 +144,8 @@ enum KeychainManager {
nonisolated static func loadCustomClientId() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.spotifly.config",
kSecAttrAccount as String: "spotify_custom_client_id",
kSecAttrService as String: configService,
kSecAttrAccount as String: customClientIdKey,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
Expand All @@ -157,8 +166,60 @@ enum KeychainManager {
nonisolated static func clearCustomClientId() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.spotifly.config",
kSecAttrAccount as String: "spotify_custom_client_id",
kSecAttrService as String: configService,
kSecAttrAccount as String: customClientIdKey,
]
SecItemDelete(query as CFDictionary)
}

// MARK: - Auth Mode (Custom vs Keymaster)

/// Saves whether user is using custom client ID mode
nonisolated static func saveUseCustomClientId(_ useCustom: Bool) throws {
clearUseCustomClientId()

let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: configService,
kSecAttrAccount as String: useCustomClientIdKey,
kSecValueData as String: (useCustom ? "true" : "false").data(using: .utf8)!,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
]

let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}

/// Loads whether user is using custom client ID mode (default: false)
nonisolated static func loadUseCustomClientId() -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: configService,
kSecAttrAccount as String: useCustomClientIdKey,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]

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

guard status == errSecSuccess,
let data = result as? Data,
let value = String(data: data, encoding: .utf8)
else {
return false
}
return value == "true"
}

/// Clears the auth mode setting from the keychain
nonisolated static func clearUseCustomClientId() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: configService,
kSecAttrAccount as String: useCustomClientIdKey,
]
SecItemDelete(query as CFDictionary)
}
Expand All @@ -171,7 +232,7 @@ enum KeychainManager {

let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrService as String: oauthService,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
Expand All @@ -187,7 +248,7 @@ enum KeychainManager {
private static func load(key: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrService as String: oauthService,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
Expand All @@ -206,7 +267,7 @@ enum KeychainManager {
private static func delete(key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrService as String: oauthService,
kSecAttrAccount as String: key,
]

Expand Down
Loading