Manual signing of requests in iOS with Amazon Cognito Identity Pools (AWS Signature version 4)

Sample code: https://github.com/dennislitjens/ios-awssigning-manual

Amazon Cognito is an Amazon Web Service for managing user authentication and access control. Amazon Cognito Identity Pools enable you to create unique IAM identities for your users and federate them with identity providers. It can integrate with well-established third-party identity providers like Facebook, Google, Slack, ….

If you want to use Amazon Cognito Identity Pools in your backend and make signed calls to it with iOS, the standard way is to use the AWS Amplify CLI together with the AWS SDK, as is described in the AWS documentation. This lets you quickly add backend features to your application, so that you can focus on your application code.

The disadvantage of this is that currently, AWS does not provide a way to use them separately. When your backend gets created separately from your frontend, AWS does not provide a standard way to use the AWS SDK and make signed calls. I tried to find a workaround for this by signing the calls manually and making use of the library: AWSMobileClient.

On top of that, this gives you more control and freedom to engineer your network layer. In my case par example, I can now let iOS wait for network connectivity before making the call, which is not possible with the AWS SDK. So, keep reading if you’re in the same situation as I was or just want to do the signing manual.

In this example I made use of the federated sign in with Slack but this part can be easily replaced with another platform that implements the SAML or OAuth2 protocol like Facebook, which explanation you can find here.

The Cognito authorization tokens expire within an hour and AWSMobileClient does not provide a way to refresh them, so I also provided a workaround in this post.

In this example I made use of AWS Signature version 4, where I based the creating of the signed headers on this post by Jeff Lewis and following part of the AWS documentation.

I assume that you’ve already have your AWS backend because you’re reading this. As a result, you already own an awsconfiguration.json config file from your backend, which you need to complete this post. If not, check the AWS documentation.

The complete code of this sample can be found here. The example only contains one controller, where the user can sign in with Slack and make a signed call afterwards. It won’t do anything for you because I didn’t make a separate test backend for this example. As a result, it does not contain a valid awsconfiguration.json file but you’re free to look through the code.

The complete process consists of the following steps:

  1. Open Slack authentication URL with browser and logging in to Slack.
  2. Slack sends a Slack authentication token to the app via a callback URL.
  3. This token is used to fetch a Slack Authorization token by the Slack authorization endpoint. This token can be used to fetch a Cognito authorization token from your backend. This call has to be signed with guest AWS credentials.
  4. Initializing the AWSMobileClient and use it to fetch guest AWS credentials.
  5. Making a signed request with guest credentials to your backend to exchange your Slack authorization token for an AWS Cognito authorization token.
  6. Using this token to do a federate sign in with the AWSMobileClient.
  7. Finally, receiving AWS credentials which can be used to make signed calls to your backend.

The first three steps are specific to Slack but can be easily replaced with the method of your choice. Therefore, we will go further with initializing the AWSMobileClient. This library provides all kinds of methods to handle authentication. Because we work without the AWS SDK and sign our calls manually in this example, we only use it to sign in and handle credentials, but it can be used for much more than that. It uses the awsconfiguration.json config file to make a connection with your backend so without this config file it won’t work. This file HAS to be in the root of your project while running. So, it is your choice if you store it there or copy it to the root as part of a build phase.

1. Initializing AWSMobileClient

After you’ve added the AWSMobileClient library to your project with your favorite package manager, you can initialize the AWSMobileClient with the following code:

AWSMobileClient.sharedInstance().initialize { (userState, error) in
                if let error = error {
                    os_log("Error initializing AWSMobileClient: ‰@", type: .error, error.localizedDescription)
                } else if userState != nil {
                    os_log("AWSMobileClient initialized successfully", type: .debug)
                }
}

The client has to be initialized always before you can use it. The methods literally don’t do anything otherwise, not even give an error. So, if you find yourself in a situation where an AWSMobileClient method does not execute, it is probably because the client is not initialized before executing that method.

So, a good place to do this is somewhere in your AppDelegate. With initializing it, the client checks if it can make a valid connection to the backend with the parameters specified in the config file. After doing this the user is automatically signed in as a guest. Now we can fetch his guest credentials.

2. Get guest credentials

This is done with the method: getAWSCredentials(), which will return an access, secret and session key. I store them in UserDefaults but the way you provide them to the next step is up to you.

AWSMobileClient.sharedInstance().getAWSCredentials { [weak self] (credentials, error)  in
               if let credentials = credentials, let sessionKey = credentials.sessionKey {
                    let codableCredentials = AwsCredentials(
                        accessKey: credentials.accessKey,
                        secretKey: credentials.secretKey,
                        sessionKey: sessionKey
                    )
                    self?.userDefaultsService.saveAwsCredentials(awsCredentials: codableCredentials)
                } else if let error = error {
                    os_log("\nError AwsCredentials: %@", type: .error, error.localizedDescription)
}

3. Signing a request

For exchanging the Slack authorization token for the AWS Cognito authorization token, a POST request has to be made, signed with the guest credentials. This process is the same for every other request to the backend, except for the other calls we use the actual and not the guest credentials. This is the hard part of this process since we do this manual. I will sum up the main parts of the headers but not go into full detail about how to calculate different signatures and so on. The complete code of this signing can be found in the GitHub repo in AwsSigning/UrlRequestSigner.swift.

This project uses the library Alamofire which let you assign an adapter to the SessionManager that can edit the request before they get sent. In this case the adapter adds the headers for the signing. When the Aws Credentials are stored in UserDefaults, it is very easy to access these in the adapter.

The call has to contain the five following headers:

  1. Host;
  2. Authorization
  3. X-Amz-Security-Token
  4. X-Amz-Date

Host:

Contains the URL of the backend. It HAS to be the same as specified in the awsconfiguration.json file.

Authorization:

This header has the following format at which the parameters in curly braces have to be filled in with your personal values:

{HMACShaType} 
Credential={accessKey}/{date}/{region}/{serviceType}/ aws4_request, SignedHeaders=content-type;host;x-amz-date,
Signature={signature}”

An example with made up values:

"AWS4-HMAC-SHA256 
Credential=ASIAFKQFZUENCJGPQJDE/20190417/eu-west-1/execute-api/aws4_request, 
SignedHeaders=content-type;host;x-amz-date, 
Signature=37df38sf38fs38dfg38dj39jnsh3028dh38jcxu302jdlqp389dhxk78jdxlqk8j"
  • HMACShaType is the type of HMAC signing, used in the signature. In our case this is: AWS4-HMAC-SHA256.
  • Accesskey is the access key of the above AWS Credentials.
  • Date is the current date in following format: yyyyMMdd.
  • Region is the Cognito region, which has to be the same as specified in the config file. In my case this is: “eu-west-1” but maybe something different in your case.
  • ServiceType is the type of the service you use to make calls, which in our case is: execute-api.
  • Signature is a HMAC hash of a combination of the request, the secret key from the AWS Credentials and the current date. For the full implementation, I refer you to the project on GitHub because that would take us too far.

X-Amz-Security-Token and X-Amz-Date:

X-Amz-Security-Token is the session key from the AWS credentials.
X-Amz-Date is the current date in the ISO-8601 format which is: yyyyMMdd’T’HHmmssZ. This date has to be the same value (not same format) as in the signature and within a few minutes of making the request.

After these headers are set, the request can be executed and if everything is correct, you should successfully exchange the Slack (or another platform) for the Cognito authorization token.

4. Federated Signin

Next, we can do a federated sign in with the AwsMobileClient, making use of the just received Cognito authorization token. For this we use the method: federatedSignIn().

AWSMobileClient.sharedInstance().federatedSignIn(
                providerName: IdentityProvider.developer.rawValue,
                token: cognitoTokens.token,
                federatedSignInOptions: FederatedSignInOptions(cognitoIdentityId: cognitoTokens.identityId),
                completionHandler: { (userState, error) in
                    if let userState = userState {
                        os_log("\nSigned in as: %@", type: .debug, userState.rawValue)
                    } else if let error = error {
                        os_log("\nError federated sign in: %@", type: .error, error.localizedDescription).
                    }
                }
)

After this, the user is signed in, and we can finally fetch the real credentials with the same getAwsCredentials() method from step 1. We then use these credentials to do the same signing process as in step 3 (but with the actual credentials) to make signed requests to our backend.

5. Refreshing Credentials

Doing the signing manual has the disadvantage that these credentials do not get automatically refreshed when they expire after an hour. The AwsMobileClient library also does not provide a way to refresh these credentials or even check if they are expired. Therefore, the only workaround I could find was to check for a 403 Forbidden status code in the network response and in response to that, sign out and literally do the entire sign in process again which will give you valid credentials. Then these credentials can again be used to retry the request.

In my case, the Slack authorization token does always stay valid. It is sufficient to start that process immediately from initializing the client and signing is as a guest, but this could be different if you use another platform where the platform token does not stay valid.

7. Conclusion

Secure requests are now more important than ever. Amazon Cognito provides a powerful and flexible solution for securing your application. Unfortunately, Amazon does not provide a standard way for implementing it without the Amplify CLI. Hopefully they will roll this out in the future since especially the above refreshing of credentials is a pretty dirty hack. Despite that, I hope you have been helped a little further with this post in the meantime.

References:

1. https://medium.com/@lewisjkl/signing-aws4-31dcff1bf1f0
2. https://aws-amplify.github.io/docs/ios/start
3. https://docs.aws.amazon.com/general/latest/gr/signature-v4-test-suite.html