Architecture Swift

Storing and retrieving token from the Keychain best practice.

There’s no doubt that the token using to authenticate your requests to the backend is sensitive data, but I’ve seen many codebases just storing it using UserDefaults which is a bad practice. In this article we aren’t going to discuss about why UserDefaults isn’t a good place for saving sensitive data or why does the Keychain is safer, and we even not gonna dive too dive into how to use Keychain. In this article I want to show you a best practice to create a component that can help you save and retrieve your token from the Keychain without using any 3rd-frameworks. Let’s get started.

What are we going to save ?

I want to take it a step further than just saving a simple access token string. Let’s say if we need to save an object that includes not only access token but expired date and refresh token using for refreshing token later on. So imagine we have a following Auth struct need to be saved:

struct Auth {
    let accessToken: String
    let expiredDate: Date
    let refreshToken: String
}

Saving operation

Create a new Swift file named it KeychainTokenStore and then add a new function as bellow:

final class KeychainTokenStore {
   func save(auth: Auth) throws {
       // TODO 1: Implement saving operation
   }
}

Because saving operation might fail so we mark the save(auth:) method as a throwable method. Saving information into the Keychain is fairly simple, just two steps:

  1. Create a query as a dictionary including:
    • A unique key will be used to retrieve later.
    • A key that indicate the type of data we need to save. In this case we can use kSecClassGenericPassword.
    • The information need to be saved.
  2. Call SecItemAdd(query, result) to make a request to the Keychain that you want to save a new data with the given query.

Replace the //TODO 1: with the following code:

// TODO 2: convert auth to data
let query = [
    kSecClass: kSecClassGenericPassword,
    kSecAttrAccount: key,
    kSecValueData: data
] as CFDictionary

guard SecItemAdd(query, nil) == noErr else {
     throw KeychainError.saveFailed
}

The complier doesn’t happy because we missing a key, an error type and we need data (of type Data). To fix it, let’s create a property represents the unique key first:

private var key: String {
      return "KeychainTokenStore.AuthKey"
}

Secondly, let’s create an enum represents for error:

enum KeychainError: Swift.Error {
     case saveFailed
}

What’s about the data, then ? We talk about it in the next section 🙂

Avoid leaking implementation details

Again, What’s about the data ? We can easily convert the auth object to data using JSONEncoder().encode(auth) function, but in order to do it, we also need to make the Auth struct implement Encodable protocol.

struct Auth: Encodable { ... }

Now we can replace the // TODO 2: with this single line of code:

let data = try JSONEncoder().encode(auth)

The complier is happy now… but I don’t. Just by making Auth conforms to Encodable we are leaking the implementation details to the domain model (Auth). We make the Auth struct conforms to Encodable just because of the implementation of the Keychain store, it makes other teams using the Auth struct confuses. In addition, imagine some point in the feature we don’t want to use Keychain anymore and decide to switch to another saving/retrieving strategy such as some kind of encryption, and we might not need the Auth to conform Encodable anymore. So better we should remove this knowledge from domain module and find a better solution.

So let undo all the code since this section and the compiler complains again 😆, great ! A good solution for this situation is to create a model mirror the domain model and only implementation detail have knowledge about this model. Inside the KeychainTokenStore class, create a internal struct as following:

struct CodableAuth: Codable {
    let accessToken: String
    let expiredDate: Date
    let refreshToken: String
        
    init(auth: Auth) {
        self.accessToken = auth.accessToken
        self.expiredDate = auth.expiredDate
        self.refreshToken = auth.refreshToken
    }
}

Why don’t just Encodable but Codable ? Actually it’s for retrieving purpose, because we also need to convert back from CodableAuth object to Auth object, so make it implement Codable so we don’t need to modify later 😄.

Alright, we got everything we need. Now we can replace the // TODO 2: with this single line of code:

let data = try JSONEncoder().encode(CodableAuth(auth: auth))

The complier and I happy now 🥳. . But we’re still missing one thing, that is we need to remove the previous saved data before save a new one. So make sure that you add this line before you call SecItemAdd :

SecItemDelete(query)

Eventually, the save function will look like this:

    func save(auth: Auth) throws {
        let data = try JSONEncoder().encode(CodableAuth(auth: auth))
        
        let query = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrAccount: key,
            kSecValueData: data
        ] as CFDictionary
        
        SecItemDelete(query)
        
        guard SecItemAdd(query, nil) == noErr else {
            throw KeychainError.saveFailed
        }
    }

Loading Operation

The loading operation is very similar to the saving operation. So we’ll do it quickly.

Let’s create a new error type for the loading data not found error. So our KeychainError enum will look like this:

enum KeychainError: Swift.Error {
        case dataNotFound
        case saveFailed
}

Next we implement what I mentioned above, inside CodableAuth we create a computed variable to convert back from CodableAuth object to Auth object. So the CodableAuth will look like this:

struct CodableAuth: Codable {
        let accessToken: String
        let expiredDate: Date
        let refreshToken: String
        
        init(auth: Auth) {
            self.accessToken = auth.accessToken
            self.expiredDate = auth.expiredDate
            self.refreshToken = auth.refreshToken
        }
        
        var model: Auth {
            return Auth(accessToken: accessToken, expiredDate: expiredDate, refreshToken: refreshToken)
        }
}

Now we’re ready to implement the load function. To load things from the Keychain you also need a query, once you have it you can call SecItemCopyMatching to get your data match the given query if any :

func load() throws -> Auth {
    let query = [
        kSecClass: kSecClassGenericPassword,
        kSecAttrAccount: key,
        kSecReturnData: kCFBooleanTrue as Any,
        kSecMatchLimit: kSecMatchLimitOne
    ] as CFDictionary
        
    var result: AnyObject?
    let status = SecItemCopyMatching(query, &result)
        
    guard status == noErr, let data = result as? Data else {
        throw KeychainError.dataNotFound
    }
        
    let token = try JSONDecoder().decode(CodableAuth.self, from: data)
    return token.model
}

Congratulations, the component is now ready to be used. Of course you might need another to clear the Keychain once user log out but I will leave it as an exercise for you guys.

One more step further

As I mentioned above, in the future we might switch to another strategy instead of using keychain, but in the clients perspective, they DON’T need to know or aware of which strategy do we use, they just want to make a request and get the token or make a request and the token will be saved, that’s it ! But up until now, we force our clients to interact directly to the KeychainTokenStore. So in order to solve this problem, we can put an abstraction in between, from the KeychainTokenStore implementation we can extract save and load functions to a protocol or even two separated protocols to respect to the interface segregation principle from SOLID.

protocol AuthSaver {
    func save(auth: Auth) throws
}
protocol AuthLoader {
    func load() throws -> Auth
}

And then make the KeychainTokenStore conforms to the protocols. Now our clients can just interact with the abstractions only, and unaware of everything behind the scenes which is make our code much more flexible, also enable testability when you can test your KeychainTokenStore through some kind of mock AuthLoader and AuthSaver 🥳

client only interact with the abstractions

Here’s full source code of the KeychainTokenStore implementation:

final class KeychainTokenStore {
    struct CodableAuth: Codable {
        let accessToken: String
        let expiredDate: Date
        let refreshToken: String
        
        init(auth: Auth) {
            self.accessToken = auth.accessToken
            self.expiredDate = auth.expiredDate
            self.refreshToken = auth.refreshToken
        }
        
        var model: Auth {
            return Auth(accessToken: accessToken, expiredDate: expiredDate, refreshToken: refreshToken)
        }
    }
    
    enum Error: Swift.Error {
        case dataNotFound
        case saveFailed
    }
    
    private var key: String {
        return "KeychainTokenStore.AccessToken"
    }
}

extension KeychainTokenStore: AuthSaver {
    func save(auth: Auth) throws {
        let data = try JSONEncoder().encode(CodableAuth(auth: auth))
        
        let query = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrAccount: key,
            kSecValueData: data
        ] as CFDictionary
        
        SecItemDelete(query)
        
        guard SecItemAdd(query, nil) == noErr else {
            throw Error.saveFailed
        }
    }

}

extension KeychainTokenStore: AuthLoader {
    func load() throws -> Auth {
        let query = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrAccount: key,
            kSecReturnData: kCFBooleanTrue as Any,
            kSecMatchLimit: kSecMatchLimitOne
        ] as CFDictionary
        
        var result: AnyObject?
        let status = SecItemCopyMatching(query, &result)
        
        guard status == noErr, let data = result as? Data else {
            throw Error.dataNotFound
        }
        
        let token = try JSONDecoder().decode(CodableAuth.self, from: data)
        return token.model
    }
}

Conclusion

From this article, you’ve known how to use Keychain to save authentication including access token and refresh token as well, not only that I’ve shown you a best practice in term of architecture decision to make our code more flexible, testable. I hope you understood all of it.

Thanks for reading, don’t forget to share if you like it, comment if you have any questions I’ll try my best to answer.

Trả lời

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *