Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
79 changes: 71 additions & 8 deletions Sources/Auth/Internal/Keychain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,24 @@
struct Keychain {
let service: String?
let accessGroup: String?
let useDataProtectionKeychain: Bool

init(
service: String?,
accessGroup: String? = nil
accessGroup: String? = nil,
useDataProtectionKeychain: Bool = false
) {
self.service = service
self.accessGroup = accessGroup
self.useDataProtectionKeychain = useDataProtectionKeychain
}

init(_ configuration: KeychainConfiguration) {
self.init(
service: configuration.service,
accessGroup: configuration.accessGroup,
useDataProtectionKeychain: configuration.useDataProtectionKeychain
)
}

private func assertSuccess(forStatus status: OSStatus) throws {
Expand All @@ -20,10 +31,18 @@
}
}

func data(forKey key: String) throws -> Data {
let query = getOneQuery(byKey: key)
var result: AnyObject?
try assertSuccess(forStatus: SecItemCopyMatching(query as CFDictionary, &result))
/// Maps a `SecItemCopyMatching` status and its result into a value.
///
/// - Returns: The stored bytes, or `nil` when the item does not exist.
/// - Throws: ``KeychainError`` for any status other than success or not-found.
static func mapReadStatus(_ status: OSStatus, result: AnyObject?) throws -> Data? {
if status == errSecItemNotFound {
return nil
}

if status != errSecSuccess {
throw KeychainError(code: KeychainError.Code(rawValue: status))
}

guard let data = result as? Data else {
let message = "Unable to cast the retrieved item to a Data value"
Expand All @@ -33,6 +52,24 @@
return data
}

/// Maps a `SecItemDelete` status, treating a missing item as success.
///
/// - Throws: ``KeychainError`` for any status other than success or not-found.
static func mapDeleteStatus(_ status: OSStatus) throws {
if status == errSecItemNotFound || status == errSecSuccess {
return
}

throw KeychainError(code: KeychainError.Code(rawValue: status))
}

func data(forKey key: String) throws -> Data? {
let query = getOneQuery(byKey: key)
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
return try Self.mapReadStatus(status, result: result)
}

func set(_ data: Data, forKey key: String) throws {
let addItemQuery = setQuery(forKey: key, data: data)
let addStatus = SecItemAdd(addItemQuery as CFDictionary, nil)
Expand All @@ -50,10 +87,10 @@

func deleteItem(forKey key: String) throws {
let query = baseQuery(withKey: key)
try assertSuccess(forStatus: SecItemDelete(query as CFDictionary))
try Self.mapDeleteStatus(SecItemDelete(query as CFDictionary))
}

private func baseQuery(withKey key: String? = nil, data: Data? = nil) -> [String: Any] {
func baseQuery(withKey key: String? = nil, data: Data? = nil) -> [String: Any] {
var query: [String: Any] = [:]
query[kSecClass as String] = kSecClassGenericPassword

Expand All @@ -69,6 +106,11 @@
if let accessGroup {
query[kSecAttrAccessGroup as String] = accessGroup
}
if useDataProtectionKeychain {
// Stored as a Swift Bool rather than kCFBooleanTrue so the value round-trips through
// `as? Bool` in tests. It bridges to a CFBoolean when the dictionary becomes a CFDictionary.
query[kSecUseDataProtectionKeychain as String] = true
}

return query
}
Expand All @@ -83,7 +125,16 @@
func setQuery(forKey key: String, data: Data) -> [String: Any] {
var query = baseQuery(withKey: key, data: data)

query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
#if os(macOS)
// kSecAttrAccessible does not apply to the legacy file-based Keychain, which is what
// SecItem targets on macOS by default. Only send it once the data-protection Keychain
// is in use. https://developer.apple.com/documentation/security/ksecattraccessible
if useDataProtectionKeychain {
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
}
#else
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
#endif

return query
}
Expand Down Expand Up @@ -228,4 +279,16 @@
lhs.code == rhs.code && lhs.localizedDescription == rhs.localizedDescription
}
}

/// The Keychain operations ``KeychainLocalStorage`` depends on.
///
/// Exists so the migration logic can be tested without reaching the real Keychain, which is
/// not available to an SPM test bundle.
protocol KeychainProtocol: Sendable {
func data(forKey key: String) throws -> Data?
func set(_ data: Data, forKey key: String) throws
func deleteItem(forKey key: String) throws
}

extension Keychain: KeychainProtocol {}
#endif
163 changes: 152 additions & 11 deletions Sources/Auth/Storage/KeychainLocalStorage.swift
Original file line number Diff line number Diff line change
@@ -1,18 +1,70 @@
#if !os(Windows) && !os(Linux) && !os(Android)
public import Foundation

/// The Keychain service used by versions of the SDK before v3.
let legacyKeychainService = "supabase.gotrue.swift"

/// Identifies a single Keychain location.
struct KeychainConfiguration: Equatable, Sendable {
var service: String?
var accessGroup: String?
var useDataProtectionKeychain: Bool
}

/// ``AuthLocalStorage`` implementation using Keychain. This is the default local storage used by the library.
public struct KeychainLocalStorage: AuthLocalStorage {
private let keychain: Keychain
let keychain: any KeychainProtocol
let legacyKeychains: [any KeychainProtocol]

/// Creates a Keychain-backed storage instance.
/// Creates a Keychain-backed storage instance scoped to the host application.
///
/// The Keychain service defaults to the host app's bundle identifier, so items are namespaced
/// per application. Sessions written by earlier SDK versions, which used a fixed
/// `"supabase.gotrue.swift"` service, migrate automatically on first read.
///
/// - Parameters:
/// - service: The Keychain service name used to namespace stored items.
/// Defaults to `"supabase.gotrue.swift"`.
/// - accessGroup: An optional Keychain access group for sharing items between apps.
Comment thread
grdsdev marked this conversation as resolved.
public init(service: String? = "supabase.gotrue.swift", accessGroup: String? = nil) {
keychain = Keychain(service: service, accessGroup: accessGroup)
/// - useDataProtectionKeychain: Targets the macOS data-protection Keychain instead of the
/// legacy file-based one. This removes the macOS consent prompt, but requires the app to
/// be signed with entitlements authorized by a provisioning profile — otherwise Keychain
/// operations fail with `errSecMissingEntitlement` (-34018). Has no effect on platforms
/// other than macOS. Defaults to `false`.
public init(accessGroup: String? = nil, useDataProtectionKeychain: Bool = false) {
let primary = Self.primaryConfiguration(
bundleIdentifier: Bundle.main.bundleIdentifier,
accessGroup: accessGroup,
useDataProtectionKeychain: useDataProtectionKeychain
)

keychain = Keychain(primary)
legacyKeychains = Self.legacyConfigurations(primary: primary).map { Keychain($0) }
}

/// Creates a Keychain-backed storage instance with an explicit service.
///
/// No migration is performed: the given service is used exactly as provided.
///
/// - Parameters:
/// - service: The Keychain service name used to namespace stored items. Pass `nil` to omit
/// the attribute entirely.
/// - accessGroup: An optional Keychain access group for sharing items between apps.
/// - useDataProtectionKeychain: See ``init(accessGroup:useDataProtectionKeychain:)``.
public init(
service: String?,
accessGroup: String? = nil,
useDataProtectionKeychain: Bool = false
) {
keychain = Keychain(
service: service,
accessGroup: accessGroup,
useDataProtectionKeychain: useDataProtectionKeychain
)
legacyKeychains = []
}

init(keychain: any KeychainProtocol, legacyKeychains: [any KeychainProtocol]) {
self.keychain = keychain
self.legacyKeychains = legacyKeychains
}

/// Stores `value` in the Keychain under `key`.
Expand All @@ -27,19 +79,108 @@

/// Returns the data stored in the Keychain for `key`, or `nil` if not present.
///
/// If the item is absent but exists in a location used by an earlier SDK version, it is moved
/// to the current location and returned.
///
/// - Parameter key: The Keychain item key.
/// - Returns: The stored bytes, or `nil` if the item does not exist.
/// - Throws: A Keychain error if the read fails.
/// - Throws: A Keychain error if the read from the current location fails. Failures while
/// probing or migrating from a legacy location are ignored — a value that was read is
/// always returned.
public func retrieve(key: String) throws -> Data? {
try keychain.data(forKey: key)
if let data = try keychain.data(forKey: key) {
return data
}

for legacy in legacyKeychains {
// A failing probe must not break a fresh install, so failures are ignored here.
guard let data = try? legacy.data(forKey: key) else { continue }
Comment thread
grdsdev marked this conversation as resolved.
Outdated

do {
try keychain.set(data, forKey: key)
// Only drop the legacy copy once the new one has landed.
try? legacy.deleteItem(forKey: key)
} catch {
// Leave the legacy copy in place; the next read retries the migration.
}

return data
}

return nil
}

/// Removes the Keychain item for `key`.
/// Removes the Keychain item for `key`, including any left in a legacy location.
///
/// - Parameter key: The Keychain item key to delete.
/// - Throws: A Keychain error if the delete fails.
/// - Throws: A Keychain error if deleting from the current location fails. Legacy-location
/// delete failures are ignored, with every location attempted regardless.
public func remove(key: String) throws {
try keychain.deleteItem(forKey: key)
var primaryError: (any Error)?
do {
try keychain.deleteItem(forKey: key)
} catch {
primaryError = error
}

for legacy in legacyKeychains {
try? legacy.deleteItem(forKey: key)
}

if let primaryError {
throw primaryError
}
}
}

extension KeychainLocalStorage {
/// Resolves the Keychain location used when the caller accepts the default service.
///
/// Falls back to ``legacyKeychainService`` when there is no bundle identifier, which is the
/// case for command-line tools and some test bundles.
static func primaryConfiguration(
bundleIdentifier: String?,
accessGroup: String?,
useDataProtectionKeychain: Bool
) -> KeychainConfiguration {
KeychainConfiguration(
service: bundleIdentifier ?? legacyKeychainService,
accessGroup: accessGroup,
useDataProtectionKeychain: useDataProtectionKeychain
)
}

/// The locations to probe, in order, when `primary` holds no value.
///
/// Entries equal to `primary`, and duplicates, are removed.
static func legacyConfigurations(
primary: KeychainConfiguration
) -> [KeychainConfiguration] {
var candidates: [KeychainConfiguration] = [
KeychainConfiguration(
service: legacyKeychainService,
accessGroup: primary.accessGroup,
useDataProtectionKeychain: false
)
]

if primary.useDataProtectionKeychain {
// Items do not move between the two macOS Keychain implementations, so the previously
// used file-based location has to be probed too.
candidates.append(
KeychainConfiguration(
service: primary.service,
accessGroup: primary.accessGroup,
useDataProtectionKeychain: false
)
)
}

var result: [KeychainConfiguration] = []
for candidate in candidates where candidate != primary && !result.contains(candidate) {
result.append(candidate)
}
return result
}
}
#endif
Loading
Loading