Ivan C Myrvold
Published 2021-05-15

Rekognition Lambda Function with Swift Part 4 - SwiftUI

In the last blog post, part4 we introduced the Amazon Cognito service to the project. In this blog post I will show how to register and log in to the Cognito service with SwiftUI.

The full source of the SwiftUI project is here.

Soto Cognito Authentication Kit

To be able to login to the Amazon Cognito service, we use the Soto Project Authentication Kit. We have added the Cognito Authenticate Kit to the Swift Package Manager as a package dependency.

LoginHandler

The LoginHandler is a ObservableObject that takes care of logging in to the Cognito service. The SwiftUI Views are observing this object for changes, and are showing any errors that comes up by popping up a custom sheet from the bottom of the screen.

We first import the Soto Cognity Authentication Kit and Identity and Identity Provider. The KeychainSwift have also been added as a dependency with Swift Package Manager, and we import it here as well. KeychainSwift is an excellent service that makes using the keychain using Swift super easy. We use KeyChain to store the tokens received from Cognito when we log in.

import Foundation
import SotoCognitoAuthenticationKit
import SotoCognitoIdentityProvider
import SotoCognitoIdentity
import Combine
import KeychainSwift

The LoginHandler class have awsClient as a property. AWSClient takes care of all communication with AWS. The cognitoIdentityProvider property is initialized with awsClient. We have also a class named CognitoConfiguration that pulls data from a plist CognitoConfig.plist. The plist contains information like poolId, clientId etch which is populated by a build script which uses aws cli to get the information from your aws account.

class LoginHandler: ObservableObject {
    struct Constants {
        static var AccessToken = "AccessToken"
        static var RefreshToken = "RefreshToken"
        static var User = "user"
    }

    lazy var awsClient = AWSClient(credentialProvider: .empty, httpClientProvider: .createNew)
    lazy var cognitoIdentityProvider = CognitoIdentityProvider(client: awsClient, region: .euwest1)
    lazy var configuration: CognitoConfiguration = {
        let poolId = cognitoConfig.getPoolId()
        let clientSecret = cognitoConfig.getClientSecret()
        let clientId = cognitoConfig.getClientId()
        let region: Region = cognitoConfig.getRegion()
        let cognitoIdentityProvider = CognitoIdentityProvider(client: awsClient, region: region)
        
       return CognitoConfiguration(userPoolId: poolId, clientId: clientId, clientSecret: clientSecret, cognitoIDP: cognitoIdentityProvider)
    }()
    lazy var authenticatable = CognitoAuthenticatable(configuration: configuration)
    lazy var cognitoConfig = CognitoConfig()

The rest of the properties are these. There are flags that the SwiftUI views uses to switch between views, and present errors or challenge sheets whenever they changes to true:

@Published var challenged = false
@Published var authenticated = false
@Published var confirmSignup = false
@Published var error = false
@Published var errorText = "" {
    didSet {
        error = true
    }
}
var challenge: CognitoAuthenticateResponse.ChallengedResponse?
var username: String?
let keychain = KeychainSwift()

The handleLogin function is used in the LoginView, and uses the Soto authenticate function when the user taps the Sign In button:

func handleLogin(username: String, password: String) {
    let data = AWSCognitoContext()
    let response = self.authenticatable.authenticate(
        username: username,
        password: password,
        requireAuthenticatedClient: false,
        clientMetadata: nil,
        context: data,
        on: authenticatable.configuration.cognitoIDP.eventLoopGroup.next()
    )
    self.username = username

    response.whenComplete { result in
        self.handleAuthentication(with: result)
    }
}
login

When the result from the handleLogin function arrives, it is handled by the handleAuthenticate functions. Depending on the result, it is setting the error property if the result is an error, resulting in an error sheet that pops up from the bottom of the screen, or setting the authenticated or challenged properties if the result is a success.

If you logs in the first time after registering a new user, you will be challenged, and that requires you to create a new password instead of the one-time password you used to log in. Setting the challenged property results in a challenged sheet to pop up from the bottom of the screen, so that you can send in the new password for the user.

func handleAuthentication(with result: Result<CognitoAuthenticateResponse, Error>) {
    DispatchQueue.main.async {
        switch result {
        case .failure(let error):
            DispatchQueue.main.async {
                self.errorText = "\(error)"
            }
        case .success(let response):
            switch response {
            case .authenticated(let authenticatedResponse):
                self.authenticated = true
                self.handleAuthentication(with: authenticatedResponse)
            case .challenged(let challengedResponse):
                self.challenged = true
                self.challenge = challengedResponse
            }
        }
    }
}

func handleAuthentication(with response: CognitoAuthenticateResponse.AuthenticatedResponse) {
    if let accessToken = response.accessToken {
        keychain.set(accessToken, forKey: Constants.AccessToken)
    }
    if let refreshToken = response.refreshToken {
        keychain.set(refreshToken, forKey: Constants.RefreshToken)
    }
}

handleChallenge function sends the new password to AWS, and when the result comes back, it is again handled by the handleAuthentication function.

func handleChallenge(with newPassword: String) {
    self.challenged = false
    guard let challenge = self.challenge, let session = challenge.session, let username = self.username else { return }
    let data = AWSCognitoContext()
    if challenge.name == CognitoChallengeName.newPasswordRequired.rawValue {
        let challengeName: CognitoChallengeName = .newPasswordRequired
        let challengeResponse = ["NEW_PASSWORD": newPassword]
        
        let newPasswordResponse = self.authenticatable.respondToChallenge(
            username: username,
            name: challengeName,
            responses: challengeResponse,
            session: session,
            requireAuthenticatedClient: false,
            context: data,
            on: self.authenticatable.configuration.cognitoIDP.eventLoopGroup.next()
        )

        newPasswordResponse.whenComplete { result in
            self.handleAuthentication(with: result)
        }

    }
}

If the user chooses to register a new user, it is handled by the registerUser function. After the user has been challenged to make a new password, the confirmSignup function sends in the confirmation code back to AWS that was sent by email to the user.

func registerUser(username: String, email: String, password: String) {
    let attributes: [String: String] = ["email": email]
    let response = authenticatable.signUp(username: username, password: password, attributes: attributes)
    
    response.whenComplete { result in
        switch result {
        case .failure(let error):
            DispatchQueue.main.async {
                self.errorText = "\(error)"
            }
        case .success(_):
            DispatchQueue.main.async {
                self.confirmSignup = true
            }
        }
    }
}

func confirmSignup(with username: String, and verificationCode: String) {
    let response = authenticatable.confirmSignUp(username: username, confirmationCode: verificationCode)
    DispatchQueue.main.async {
        self.confirmSignup = false
    }
    
    response.whenComplete { result in
        switch result {
        case .failure(let error):
            DispatchQueue.main.async {
                self.errorText = "\(error)"
            }
        case .success():
            break
        }
    }
}

Whenever the user opens the app, or the app comes to the foreground from another app, the checkToken function checks if the accessToken is still valid and haven't expired. If it has expired, we will try to use the refreshToken to make a new accessToken. The accessToken is usually set to 60 minutes, and the refreshToken to 30 days, but that can be changed in the Cognito console.

func checkToken() {
    guard let token = keychain.get(Constants.AccessToken) else { return }

    let response = authenticatable.authenticate(accessToken: token, on: authenticatable.configuration.cognitoIDP.eventLoopGroup.next())
    
    response.whenComplete { result in
        switch result {
        case .failure(_):
            self.refreshToken()
        case .success(_):
            DispatchQueue.main.async {
                self.authenticated = true
            }
        }
    }
}

func refreshToken() {
    guard let refreshToken = keychain.get(Constants.RefreshToken), let username = self.username else { return }
    let response = authenticatable.refresh(username: username, refreshToken: refreshToken, requireAuthenticatedClient: false)

    response.whenComplete { result in
        switch result {
        case .failure(let error):
            DispatchQueue.main.async {
                self.errorText = "\(error)"
                self.authenticated = false
            }
        case .success(let response):
            switch response {
            case .authenticated(let authenticatedResponse):
                DispatchQueue.main.async {
                    self.authenticated = true
                }
                self.handleAuthentication(with: authenticatedResponse)
            case .challenged(let challengedResponse):
                self.challenged = true
                self.challenge = challengedResponse
            }
        }
    }
}

Conclusion

The RekClient app can now be used to login to the Cognito service, which is used by the project I have described in the last 4 blog posts Rekognition Lambda Function. At this point it doesn't do anything else, but that will change in the next blog posts I will do in this series. The app is a multiplatform app that can theoretically also run on macOS, although I haven't at this point followed that route, but hopefully I will come back and build out that as well.

Tagged with: