Wiingaard
Wiingaard

Reputation: 4302

Store accessToken in iOS keychain

I'm looking for the simples way to store/load an accessToken and refreshToken in the iOS Keychain.

So far I've come to this:

    enum Key: String {
        case accessToken = "some.keys.accessToken"
        case refreshToken = "some.keys.refreshToken"
    
        fileprivate var tag: Data {
            rawValue.data(using: .utf8)!
        }
    }

    enum KeychainError: Error {
        case storeFailed
        case loadFailed
    }
    
    func store(key: Key, value: String) throws {
        let addQuery: [String: Any] = [
            kSecClass as String: kSecClassKey,
            kSecAttrApplicationTag as String: key.tag,
            kSecValueRef as String: value
        ]
        let status = SecItemAdd(addQuery as CFDictionary, nil)
        guard status == errSecSuccess else {
            print("Store key: '\(key.rawValue)' in Keychain failed with status: \(status.description)")
            throw KeychainError.storeFailed
        }
    }
    
    func load(key: Key) throws -> String? {
        let getQuery: [String: Any] = [
            kSecClass as String: kSecClassKey,
            kSecAttrApplicationTag as String: key.tag,
            kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
            kSecReturnRef as String: true
        ]
        
        var item: CFTypeRef?
        let status = SecItemCopyMatching(getQuery as CFDictionary, &item)
        guard status == errSecSuccess else { 
            print("Load key: '\(key.rawValue)' in Keychain failed with status: \(status.description)")
            throw KeychainError.loadFailed 
        }
        return item as? String
    }

But this fail with messages:

When running store:

Store key: 'some.keys.accessToken' in Keychain failed with status: -50

When running load:

Load key: 'some.keys.accessToken' in Keychain failed with status: -25300

What am I doing wrong here?

Upvotes: 2

Views: 7064

Answers (2)

Bram
Bram

Reputation: 3264

As per recommendations of Apple, you should use a kSecClassGenericPassword class to store arbitrary data, i.e. tokens, securely. To do that properly, you'll need a String identifier to store under the kSecAttrAccount key and a Data representation of the secure value to store under the kSecValueData key. You can easily transform your string value to Data by doing the following (assuming the token contains UTF8 data).

tokenString.data(using: .utf8)
// or
Data(tokenString.utf8)

First some nice to haves.

/// Errors that can be thrown when the Keychain is queried.
enum KeychainError: LocalizedError {
    /// The requested item was not found in the Keychain.
    case itemNotFound
    /// Attempted to save an item that already exists.
    /// Update the item instead.
    case duplicateItem
    /// The operation resulted in an unexpected status.
    case unexpectedStatus(OSStatus)
}

/// A service that can be used to group the tokens
/// as the kSecAttrAccount should be unique.
let service = "com.bundle.stuff.token-service"

Inserting a token into the Keychain.

func insertToken(_ token: Data, identifier: String, service: String = service) throws {
    let attributes = [
        kSecClass: kSecClassGenericPassword,
        kSecAttrService: service,
        kSecAttrAccount: identifier,
        kSecValueData: token
    ] as CFDictionary

    let status = SecItemAdd(attributes, nil)
    guard status == errSecSuccess else {
        if status == errSecDuplicateItem {
            throw KeychainError.duplicateItem
        }
        throw KeychainError.unexpectedStatus(status)
    }
}

The retrieval will be done as follows.

func getToken(identifier: String, service: String = service) throws -> String {
    let query = [
        kSecClass: kSecClassGenericPassword,
        kSecAttrService: service,
        kSecAttrAccount: identifier,
        kSecMatchLimit: kSecMatchLimitOne,
        kSecReturnData: true
    ] as CFDictionary

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

    guard status == errSecSuccess else {
        if status == errSecItemNotFound {
            // Technically could make the return optional and return nil here
            // depending on how you like this to be taken care of
            throw KeychainError.itemNotFound
        }
        throw KeychainError.unexpectedStatus(status)
    }
    // Lots of bang operators here, due to the nature of Keychain functionality.
    // You could work with more guards/if let or others.
    return String(data: result as! Data, encoding: .utf8)!
}

Note that a generic password has certain specifications, as mentioned before, and I guess the most important one, is that the kSecAttrAccount flag must be unique for each token you store. You cannot store access token A and access token B for the same identifier. This will cause the .duplicateItem error to trigger.

I'd also like to point out that the OSStatus website is very useful for getting more info about your error code. Besides the website there is also the SecCopyErrorMessageString(OSStatus, UnsafeMutableRawPointer?) function that can get you more information about your error code.

Now that technically answers your question, but below I've added some more nice to haves. Update updates a token value for an existing item, make sure the item exists before calling update!. Upsert inserts a token when it doesn't already exist, if it does, it will update the token value. Delete will remove the token value from the Keychain.

func updateToken(_ token: Data, identifier: String, service: String = service) throws {
    let query = [
        kSecClass: kSecClassGenericPassword,
        kSecAttrService: service,
        kSecAttrAccount: identifier
    ] as CFDictionary

    let attributes = [
        kSecValueData: token
    ] as CFDictionary

    let status = SecItemUpdate(query, attributes)
    guard status == errSecSuccess else {
        if status == errSecItemNotFound {
            throw KeychainError.itemNotFound
        }
        throw KeychainError.unexpectedStatus(status)
    }
}

func upsertToken(_ token: Data, identifier: String, service: String = service) throws {
    do {
        _ = try getToken(identifier: identifier, service: service)
        try updateToken(token, identifier: identifier, service: service)
    } catch KeychainError.itemNotFound {
        try insertToken(token, identifier: identifier, service: service)
    }
}

func deleteToken(identifier: String, service: String = service) throws {
    let query = [
        kSecClass: kSecClassGenericPassword,
        kSecAttrService: service,
        kSecAttrAccount: identifier
    ] as CFDictionary

    let status = SecItemDelete(query)
    guard status == errSecSuccess || status == errSecItemNotFound else {
        throw KeychainError.unexpectedStatus(status)
    }
}

Upvotes: 10

CSmith
CSmith

Reputation: 13458

Please try the following edits, marked with comments:

   enum KeychainError: Error {
        case storeFailed
        case loadFailed
   }
        
   func store(key: Key, value: String) throws {
        let addQuery: [String: Any] = [
            kSecClass as String: kSecClassKey,
            kSecAttrApplicationTag as String: key.tag,
    // use kSecValueData, converting your String to Data
            kSecValueData as Data: value.data(using: .utf8)
        ]
        let status = SecItemAdd(addQuery as CFDictionary, nil)
        guard status == errSecSuccess else {
            print("Store key: '\(key.rawValue)' in Keychain failed with status: \(status.description)")
            throw KeychainError.storeFailed
        }
   }
        
   func load(key: Key) throws -> String? {
       let getQuery: [String: Any] = [
            kSecClass as String: kSecClassKey,
            kSecAttrApplicationTag as String: key.tag,

    // remove kSecAttrKeyType and add kSecReturnData 
            kSecReturnData as String: kCFBooleanTrue
       ]
        
    // this is different    
       var item: AnyObject?
       let status: OSStatus = withUnsafeMutablePointer(to:&item)
       { (result: UnsafeMutablePointer<AnyObject?>?) -> OSStatus in
          return SecItemCopyMatching(getQuery as CFDictionary, result)
       }

       guard status == errSecSuccess else { 
          print("Load key: '\(key.rawValue)' in Keychain failed with status: \(status.description)")
          throw KeychainError.loadFailed 
       }
    // convert Data to String
       guard let itemData = item as? Data else { throw KeychainError.loadFailed }
       return String(decoding: itemData, as: UTF8.self)
    }

how does this change the behavior?

A couple comments:

  • I'm not sure why you're using kSecAttrKeyType property to store a String
  • I'm unsure of the use of kSecValueRef vs. kSecValueData, only making this suggestion based on what has worked for me

Hope this helps!?

Upvotes: -1

Related Questions