Front-End Web & Mobile

Integrating Amazon Cognito with the Android AccountManager API

This is the fourth part in a six-part series on synchronizing data within an Android mobile app to the AWS Cloud.  Check out the full series:

If you have an Android phone, you can go into Settings > Account and add accounts for your internet services like Gmail.  However, this area is an extensible on-device secure identity store that other applications can use to store their own credentials.  If your app needs to synchronize with a cloud-based data store, you need to implement your own account manager to use the Android Sync Framework.

You could provide a sign-in and sign-up UI within your app and never have to think about the Account Manager.  Many apps do this already.  However, there are several advantages to Account Manager that make it worthwhile to understand.  The Account Manager is the standard method on Android to authenticate users. It simplifies the process for the developer.  It automatically handles several scenarios for you (like access denied and multiple token types) and can easily share authentication tokens between cooperating apps. Finally, and likely most importantly if you are reading this series of articles, it has support for background processes like synchronization services.

Your account type also appears as an entry within the phone settings, as shown in the following image.

To integrate with the Account Manager, you:

  • Create the authenticator, which does the actual work.
  • Create the activities where users enter their credentials.
  • Create the service so we can communicate with the authenticator.

Whenever your app needs an authentication token, it only communicates with one method – the AccountManager.getAuthToken() method.  The Account Manager authenticates the user, displaying a UI that is designated, if necessary.

You can implement the authentication service (including the authenticator, activities, and service) as an apklib so that it can be used by multiple apps, or you can integrate it into a single apk with your app.

Create the authenticator

The account authenticator is the class that the Account Manager uses to fulfill all the tasks necessary – getting the stored authentication token, presenting the account login-in screen, and handling the user authentication with the server (in this case Amazon Cognito). This is code you need to write by extending the AbstractAccountAuthenticator. There are a number of methods to implement:

  • addAccount() is called when the user clicks the Add Account button in the Account page of the Settings app.
  • getAuthToken() is called when an app (including your app and the sync framework) tries to retrieve an authentication token for the user from a previous successful login on this device. If one is not available, the user is prompted to log in.

In both cases, this is mostly boilerplate code. You substitute the name of your Activity, which does the authentication, and you manage the token type. In the following code, we show only one token type.

package com.amazonaws.mobile.samples.notetaker.auth;

import android.accounts.AbstractAccountAuthenticator;
import android.accounts.Account;
import android.accounts.AccountAuthenticatorResponse;
import android.accounts.AccountManager;
import android.accounts.NetworkErrorException;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;

/**
 * Integrates with the Android Account Manager to implement an Authenticator
 * based on Amazon Cognito.
 */
public class CognitoAuthenticator extends AbstractAccountAuthenticator {
    private static final String _TAG = CognitoAuthenticator.class.getSimpleName();
    private final Context mContext;

    public CognitoAuthenticator(Context context) {
        super(context);
        this.mContext = context;
    }

    @Override
    public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) {
        return null;
    }
    
    @Override
    public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType, String[] requiredFeatures, Bundle options) throws NetworkErrorException {
        Log.d(_TAG, "addAccount");

        final Intent intent = new Intent(mContext, CognitoAuthenticatorActivity.class);
        intent.putExtra(CognitoAuthenticatorActivity.KEY_ACCOUNTTYPE, accountType);
        intent.putExtra(CognitoAuthenticatorActivity.KEY_AUTHTOKENTYPE, authTokenType);
        intent.putExtra(CognitoAuthenticatorActivity.KEY_ISADDACCOUNT, true);
        intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);

        final Bundle bundle = new Bundle();
        bundle.putParcelable(AccountManager.KEY_INTENT, intent);
        return bundle;
    }

    @Override
    public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options) throws NetworkErrorException {
        return null;
    }

    @Override
    public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException {
        Log.d(_TAG, "getAuthToken");

        if (!AuthTokenType.isValidAuthTokenType(authTokenType)) {
            final Bundle result = new Bundle();
            result.putString(AccountManager.KEY_ERROR_MESSAGE, "invalid authTokenType");
            return result;
        }

        final AccountManager am = AccountManager.get(mContext);
        String authToken = am.peekAuthToken(account, authTokenType);
        Log.d(_TAG, "peekAuthToken returned " + authToken);

        // We have an auth token and it is valid, so just return it
        if (!TextUtils.isEmpty(authToken)) {
            final Bundle result = new Bundle();
            result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name);
            result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type);
            result.putString(AccountManager.KEY_AUTHTOKEN, authToken);
            return result;
        }

        // We don't have an auth token, so ask the user to authenticate
        // Note the symmetry between this and the intent in addAccount
        final Intent intent = new Intent(mContext, CognitoAuthenticatorActivity.class);
        intent.putExtra(CognitoAuthenticatorActivity.KEY_ACCOUNTTYPE, account.type);
        intent.putExtra(CognitoAuthenticatorActivity.KEY_AUTHTOKENTYPE, authTokenType);
        intent.putExtra(CognitoAuthenticatorActivity.KEY_ACCOUNTNAME, account.name);
        intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);

        final Bundle bundle = new Bundle();
        bundle.putParcelable(AccountManager.KEY_INTENT, intent);
        return bundle;
    }

    @Override
    public String getAuthTokenLabel(String authTokenType) {
        return AuthTokenType.toString(authTokenType);
    }

    @Override
    public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException {
        return null;
    }

    @Override
    public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, String[] features) throws NetworkErrorException {
        final Bundle result = new Bundle();
        result.putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false);
        return result;
    }

The AuthTokenType class is as follows:

package com.amazonaws.mobile.samples.notetaker.auth;

import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

/**
 * List of supported authentication token types
 */
public class AuthTokenType {
    private static final Map<String, String> sAuthTokenTypes;
    
    static {
        Map<String, String> aStringMap = new HashMap<String, String>();
        aStringMap.put("rw", "Read-Write");
        sAuthTokenTypes = Collections.unmodifiableMap(aStringMap);
    }
    
    public static boolean isValidAuthTokenType(String authTokenType) {
        return sAuthTokenTypes.containsKey(authTokenType);
    }

    public static String toString(String authTokenType) {
        if (sAuthTokenTypes.containsKey(authTokenType)) {
            return sAuthTokenTypes.get(authTokenType);
        } else {
            return null;
        }
    }
}

This is a read-only mapping of the valid auth token types (we only have one called rw) and their common display names (Read-Write).  You can add other types here and provide the appropriate code to produce them when you prompt for authentication.

Create the Activity

You must provide a UI that the user can interact with during the sign-in and sign-up process. This is an Activity class that derives from AccountAuthenticatorActivity. This activity shows the user a log-in form, authenticates with the server, and returns the result via the setAccountAuthenticatorResult() method to the calling authenticator. In this case, we use the IdentityManager, which is built into the AWS Mobile SDK for Android. To set up the appropriate configuration for Amazon Cognito, use AWS Mobile Hub:

  1. Open the AWS Mobile Hub console.  If you do not have an AWS account, sign up for the AWS Free Tier.
  2. If this is not your first project, click Create a new project.
  3. Type a name for your project, and then click Create project.
  4. Click the User Sign-in tile.
  5. Click Email and Password.
  6. Scroll to the bottom of the page, and then click Create user pool.
  7. Click Integrate in the left menu.
  8. Click Download in step 1.

This downloads the awsconfiguration.json file that contains all of the resource constants for your project.  You have no configured your project to include a simple sign-up / sign-in flow with email verification of the account.

To add the awsconfiguration.json file to your project:

  1. Expand the app folder.
  2. Right-click the res folder.
  3. Choose New > Android resource directory.
  4. Select Resource type: raw
  5. Click OK.
  6. Place the awsconfiguration.json file in the created app/src/main/res/raw directory within your project.

Next, add the AWS Mobile SDK for Android to your project.  Add the following to the app/build.gradle file:

dependencies {
    # Other dependencies will be listed here  
    compile 'com.amazonaws:aws-android-sdk-core:2.6.3’
    compile 'com.amazonaws:aws-android-sdk-auth-core:2.6.3@aar'
    compile 'com.amazonaws:aws-android-sdk-auth-ui:2.6.3@aar'
    compile 'com.amazonaws:aws-android-sdk-auth-userpools:2.6.3@aar'
    compile 'com.amazonaws:aws-android-sdk-cognitoidentityprovider:2.6.3'
}

Then edit AndroidManifest.xml to include appropriate permissions to access AWS services:

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />

Finally, create the CognitoAuthenticatorActivity.java file as follows:

package com.amazonaws.mobile.samples.notetaker.auth;

import android.accounts.Account;
import android.accounts.AccountAuthenticatorActivity;
import android.accounts.AccountManager;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.widget.Toast;

import com.amazonaws.mobile.auth.core.DefaultSignInResultHandler;
import com.amazonaws.mobile.auth.core.IdentityManager;
import com.amazonaws.mobile.auth.core.IdentityProvider;
import com.amazonaws.mobile.auth.ui.AuthUIConfiguration;
import com.amazonaws.mobile.auth.ui.SignInActivity;
import com.amazonaws.mobile.auth.userpools.CognitoUserPoolsSignInProvider;
import com.amazonaws.mobile.config.AWSConfiguration;
import com.amazonaws.mobile.samples.notetaker.R;

public class CognitoAuthenticatorActivity extends AccountAuthenticatorActivity {
    public static final String ACCOUNT_TYPE = "com.amazonaws.mobile.samples.notetaker.cognito";

    public static final String KEY_ACCOUNTTYPE = "cognito.account.type";
    public static final String KEY_ACCOUNTNAME = "cognito.account.name";
    public static final String KEY_AUTHTOKENTYPE = "cognito.authtoken.type";
    public static final String KEY_ISADDACCOUNT = "cognito.addaccount";

    private AccountManager mAccountManager;
    private String mAuthTokenType;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_cognito_authenticator);

        mAccountManager = AccountManager.get(getBaseContext());
        mAuthTokenType = getIntent().getStringExtra(KEY_AUTHTOKENTYPE);
        if (mAuthTokenType == null) {
            mAuthTokenType = AuthTokenType.DEFAULT_AUTHTOKENTYPE;
        }

        final AWSConfiguration awsConfig = new AWSConfiguration(getApplicationContext());
        final IdentityManager identityManager = new IdentityManager(getApplicationContext(), awsConfig);
        IdentityManager.setDefaultIdentityManager(identityManager);
        identityManager.addSignInProvider(CognitoUserPoolsSignInProvider.class);
        identityManager.setUpToAuthenticate(this, new DefaultSignInResultHandler() {
            @Override
            public void onSuccess(Activity callingActivity, IdentityProvider provider) {
                String authtoken = provider.getToken();
                String username = provider.getCognitoLoginKey();

                // Create a bundle for the result
                final Bundle extras = new Bundle();
                extras.putString(AccountManager.KEY_ACCOUNT_NAME, username);
                extras.putString(AccountManager.KEY_ACCOUNT_TYPE, ACCOUNT_TYPE);
                extras.putString(AccountManager.KEY_AUTHTOKEN, authtoken);

                // Create an intent to hold the result
                final Intent result = new Intent();
                result.putExtras(extras);

                // Produce the account.  If  the account is new, create it.
                final Account account = new Account(username, ACCOUNT_TYPE);
                if (getIntent().getBooleanExtra(KEY_ISADDACCOUNT, false)) {
                    mAccountManager.addAccountExplicitly(account, null, null);
                    mAccountManager.setAuthToken(account, mAuthTokenType, authtoken);
                }

                // Update the result to be ok
                setAccountAuthenticatorResult(extras);
                setResult(RESULT_OK, result);
                finish();
            }

            @Override
            public boolean onCancel(Activity callingActivity) {
                Toast.makeText(getBaseContext(), "Error authenticating", Toast.LENGTH_SHORT).show();
                return false;
            }
        });

        AuthUIConfiguration uiConfiguration = new AuthUIConfiguration.Builder()
                .userPools(true)
                .build();
        SignInActivity.startSignInActivity(this, uiConfiguration);
        CognitoAuthenticatorActivity.this.finish();
    }
}

Most of the work is done within the IdentityManager onSuccess callback, which adds the account to the Account Manager if requested. IdentityManager then sets up the result with the appropriate callback information (most notably the authentication token) and finishes the Activity.

We are using the native UI included in the AWS Mobile SDK for Android to ask for credentials.  The native UI supports the following authentication flows for Amazon Cognito User Pools, Facebook, and Google: sign-up with validation, forgot password, and sign-in with optional multi-factor authentication.

Create the service

The final class to write is the Android service that binds everything together. This is a remarkably simple class because the AbstractAccountAuthenticator (which our CognitoAuthenticator class extends) already implements the IBinder interface that we would normally have to implement. You can use that to bind the authenticator to the service:

package com.amazonaws.mobile.samples.notetaker.auth;

import android.app.Service;
import android.content.Intent;
import android.os.IBinder;
import android.support.annotation.Nullable;

/**
 * The Authenticator service is the piece that is registered with the Android system
 * and binds the authenticator to the service
 */
public class CognitoAuthenticatorService extends Service {
    @Nullable
    @Override
    public IBinder onBind(Intent intent) {
        CognitoAuthenticator authenticator = new CognitoAuthenticator(this);
        return authenticator.getIBinder();
    }
}

The service also needs to be registered with the Android system.  This requires you to define the service in the AndroidManifest.xml file within the <application> node:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.amazonaws.mobile.samples.notetaker">

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
    <uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />

    <application
        android:name=".Application"
        android:allowBackup="false"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <activity android:name=".NoteDetailActivity" />
        <activity android:name=".auth.CognitoAuthenticatorActivity"></activity>

        <provider
            android:name=".provider.NotesContentProvider"
            android:authorities="com.amazonaws.mobile.samples.notetaker.provider"
            android:label="NotesContentProvider" />

        <service android:name=".auth.CognitoAuthenticatorService">
            <intent-filter>
                <action android:name="android.accounts.AccountAuthenticator"/>
            </intent-filter>
            <meta-data android:name="android.accounts.AccountAuthenticator" 
                android:resource="@xml/authenticator" />
        </service>
    </application>
</manifest>

There are three parts here:

  1. The AUTHENTICATE_ACCOUNTS permission was added to the list of permissions.
  2. The CognitoAuthenticatorActivity activity was added as a valid activity.
  3. The <service> node was added, referencing an authenticator.xml file.

The authenticator.xml file describes the authenticator so that it can be placed in the Accounts list appropriately. It is placed in the xml resource directory. Create the xml resource directory (right-click the res folder, select New > Android resource directory, select Resource type = xml, and then click OK), then add the authenticator.xml file:

<?xml version="1.0" encoding="utf-8"?>
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
    android:accountType="com.amazonaws.mobile.samples.notetaker.cognito"
    android:icon="@drawable/ic_cognito"
    android:smallIcon="@drawable/ic_cognito"
    android:label="@string/authenticator_label"
    android:accountPreferences="@xml/prefs" />

The accountType is defined within the CognitoAuthenticatorActivity.  You can use any drawable for the icons.  The label is displayed on the Accounts page within Settings.  Finally, you must provide a list of preferences.  These can be anything you wish, and will be displayed in the Accounts preference page.  For example:

<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
    <PreferenceCategory android:title="Notetaker Preferences" />
    
    <SwitchPreference
        android:title="Sync over Wifi only"
        android:key="wifiOnly"
        android:summary="If on, sync notes only when on a wifi link" />

</PreferenceScreen>

Testing Account Manager

Run your app in the Android emulator, then immediately switch to the home screen and swipe down to access Settings. Choose Accounts, and then choose Add Account. You will see the same screen you saw before. Click your account authenticator and the following screen displays:

Create a new account (including validation), and then sign-in.  If you go back to the Accounts page after signing in, you see the preferences screen you defined:

Wrap up

There are a number of issues with this authenticator that we didn’t cover.  For example, if you fail to sign in successfully, you cannot close the authenticator.  You also can’t delete an account.  These issues would need to be solved before this code is considered production ready.  However, we will move on and look at the approved method of synchronizing offline data with a cloud service in the next article.