AWS CognitoにAndroidネイティブアプリでサインインする

今回は、Androidのネイティブアプリで、AWS Cognitoにサインインしてみます。
AWSのSDKが提供されていますが、OpenID Connectに準拠したエンドポイントを呼び出すだけなのに、内部で何をしているかわからずに、てこずりそうだったので、いっそのこと自分で実装しています。

# 大まかな流れ

サインインは、OpenID Connectに準拠したエンドポイントを呼び出しているだけです。

以下が参考になります。
　[AWS Cognitoのエンドポイントを使いこなす](https://qiita.com/poruruba/items/a284b9f522d8d6e1e3f9 )

ただし、これらエンドポイントは、ブラウザからの利用を想定しているため、そのままでは、ネイティブのAndroidアプリケーションでは使えません。
そこで、ネイティブアプリからブラウザを起動し、ブラウザでログインした結果をネイティブアプリに戻すようにしました。

ここで使うブラウザとして、通常のブラウザのほかに、Chrome Custom TabsやWebViewを使う方法があります。今回はこのうち、ブラウザまたはChrome Custom Tabsを採用しています。どちらもほとんど同じやり方なのですが、Chrome Custom Tabsの方が、見栄えも損なわず、パフォーマンスもよいのでChrome Custom Tabsの方がよいかと思います。

Chrome Custom Tabs
　https://developer.chrome.com/multidevice/android/customtabs

# AndroidManifest.xml

まずは、AndroidManifest.xmlから。

```xml:AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="パッケージ名">

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

    <application
        android:allowBackup="true"
        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" android:launchMode="singleTask">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            <intent-filter>
                <action android:name="android.intent.action.VIEW" />

                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />

                <data android:scheme="demoapp" android:host="cognito.signin.test" android:path="/mainactivity" />
            </intent-filter>
        </activity>
    </application>

</manifest>
```

サインインは当然ネットワークを介するので、ネットワーク通信のパーミッションを与えておきます。

次に、Activityに「android:launchMode="singleTask"」という属性を与えます。
これは、いったんブラウザを立ち上げた後にネイティブアプリに戻ってくるのですが、その時に別のインスタンスのアクティビティを生成して戻るのではなく、すでに立ち上がっているインスタンスに戻ってくるようにするためです。

立ち上げたブラウザからネイティブに戻ってこれるように、URL参照を受け付けるIntentを追記し、ネイティブにブラウザから参照できるように名前を付けます。
今回は、「demoapp://cognito.signin.test/mainactivity」となります。これらの値は、適当で構いません。

#CognitoのコールバックURLの指定

AWS管理コンソールにて、Cognitoでサインイン後に戻るURLを指定します。
アプリクライアントの設定のコールバックURLのところに、さきほどの名前である「demoapp://cognito.signin.test/mainactivity」を追加します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/54cd9461-80e4-1754-556f-cb03859baa81.png)

今回は、Authorization code grantでサインインします。

# Chrome Custom Tabsを使う準備

Androidネイティブアプリで、Chrome Custom Tabsを使うには、サポートライブラリを含める必要があります。ブラウザでサインインする場合はこの指定は不要です。

build.gradle に、以下を追加します。

```build.gradle
・・・
dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation 'androidx.appcompat:appcompat:1.1.0'
    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
    implementation "com.android.support:customtabs:25.4.0"  ★これを追加
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'androidx.test:runner:1.2.0'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
}
```

# MainActivity

こんな感じです。ちょっと長いですが。

```java:MainActivity.java
package パッケージ名;

import androidx.appcompat.app.AppCompatActivity;
import androidx.browser.customtabs.CustomTabsIntent;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
import org.json.JSONObject;
import java.util.HashMap;
import java.util.Map;
import java.nio.charset.Charset;
import android.util.Base64;

public class MainActivity extends AppCompatActivity implements View.OnClickListener {
    public static final String COGNITO_URL = "https://[ドメイン名].auth.ap-northeast-1.amazoncognito.com";
    public static final String REDIRECT_URL = "demoapp://cognito.signin.test/mainactivity";

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

        Button btn;
        btn = (Button)findViewById(R.id.btn_chromtab);
        btn.setOnClickListener(this);
        btn = (Button)findViewById(R.id.btn_browser);
        btn.setOnClickListener(this);
        btn = (Button)findViewById(R.id.btn_token);
        btn.setOnClickListener(this);
    }

    @Override
    public void onNewIntent(Intent intent){
        super.onNewIntent(intent);

        if( intent == null )
            return;

        String action = intent.getAction();
        if (Intent.ACTION_VIEW.equals(action)) {
            setIntent(intent);
        }
    }

    @Override
    public void onResume() {
        super.onResume();

        Intent intent = getIntent();
        String action = intent.getAction();

        if (Intent.ACTION_VIEW.equals(action)) {
            try{
                Uri uri = intent.getData();
                String code = uri.getQueryParameter("code");
                String state = uri.getQueryParameter("state");

                EditText edit;
                edit = (EditText)findViewById(R.id.edit_state);
                if( !state.equals(edit.getText().toString()) ){
                    Toast.makeText(this, "State mismatch", Toast.LENGTH_LONG).show();
                    return;
                }

                TextView text;
                text = (TextView)findViewById(R.id.txt_state);
                text.setText(state);
                text = (TextView)findViewById(R.id.txt_code);
                text.setText(code);
            }catch(Exception ex){
                Toast.makeText(this, ex.getMessage(), Toast.LENGTH_LONG).show();
            }
        }
    }

    private Map<String, String> generateTokenExchangeHeader(String client_id, String client_secret) {
        Map<String, String> httpHeaderParams = new HashMap<String, String>();

        httpHeaderParams.put("Content-Type","application/x-www-form-urlencoded");

        StringBuilder builder = new StringBuilder();
        builder.append(client_id).append(":").append(client_secret);
        byte[] data = builder.toString().getBytes(Charset.forName("ISO-8859-1"));
        String basic_params = Base64.encodeToString(data, Base64.NO_PADDING | Base64.NO_WRAP);
        httpHeaderParams.put("Authorization", "Basic " + basic_params);

        return httpHeaderParams;
    }

    private Map<String, String> generateTokenExchangeRequest(String client_id, String redirect_uri, String code) {
        Map<String, String> httpBodyParams = new HashMap<String, String>();

        httpBodyParams.put("grant_type","authorization_code");
        httpBodyParams.put("client_id",client_id);
        httpBodyParams.put("redirect_uri", redirect_uri);
        httpBodyParams.put("code", code);

        return httpBodyParams;
    }

    private Uri makeAuthorizeUri(String client_id, String redirect_uri, String state){
        Uri.Builder builder = Uri.parse(COGNITO_URL + "/login")
                .buildUpon()
                .appendQueryParameter("client_id", client_id)
                .appendQueryParameter("redirect_uri", redirect_uri)
                .appendQueryParameter("response_type", "code")
                .appendQueryParameter("state", state);
        return builder.build();
    }

    @Override
    public void onClick(View view) {
        EditText edit;
        edit = (EditText)findViewById(R.id.edit_client_id);
        String client_id = edit.getText().toString();
        edit = (EditText)findViewById(R.id.edit_client_secret);
        String client_secret = edit.getText().toString();
        edit = (EditText)findViewById(R.id.edit_state);
        String state = edit.getText().toString();

        switch( view.getId()){
            case R.id.btn_browser:{
                Uri uri = makeAuthorizeUri(client_id, REDIRECT_URL, state);
                Intent intent = new Intent(Intent.ACTION_VIEW, uri);
                startActivity(intent);
                break;
            }
            case R.id.btn_chromtab:{
                try {
                    Uri uri = makeAuthorizeUri(client_id, REDIRECT_URL, state);
                    CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
                    CustomTabsIntent intent = builder.build();
                    intent.intent.setPackage("com.android.chrome");
                    intent.intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY);
                    intent.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
                    intent.launchUrl(this, uri);
                } catch (Exception ex) {
                    Toast.makeText(this, ex.getMessage(), Toast.LENGTH_LONG).show();
                }
                break;
            }
            case R.id.btn_token: {
                TextView text;
                text = (TextView)findViewById(R.id.txt_code);
                String code = text.getText().toString();
                final Map<String, String> httpHeaderParams = generateTokenExchangeHeader(client_id, client_secret);
                final Map<String, String> httpBodyParams = generateTokenExchangeRequest(client_id, REDIRECT_URL, code);

                new ProgressAsyncTaskManager.Callback( this, "通信中です。", null )
                {
                    @Override
                    public Object doInBackground(Object obj) throws Exception {
                        JSONObject response = HttpPostJson.doPost_getToken(COGNITO_URL + "/oauth2/token", httpHeaderParams, httpBodyParams);
                        return response;
                    }

                    @Override
                    public void doPostExecute(Object obj) {
                        if (obj instanceof Exception) {
                            Toast.makeText(getApplicationContext(), ((Exception)obj).getMessage(), Toast.LENGTH_LONG).show();
                            return;
                        }
                        JSONObject response = (JSONObject)obj;

                        try {
                            TextView text;
                            text = (TextView) findViewById(R.id.txt_id_token);
                            text.setText(response.getString("id_token"));
                            text = (TextView) findViewById(R.id.txt_access_token);
                            text.setText(response.getString("access_token"));
                            text = (TextView) findViewById(R.id.txt_refresh_token);
                            text.setText(response.getString("refresh_token"));
                        }catch(Exception ex){
                            Toast.makeText(getApplicationContext(), ex.getMessage(), Toast.LENGTH_LONG).show();
                        }
                    }
                };
                break;
            }
        }
    }
}
```

COGNITO_URL の部分は、各自のCognitoのエンドポイントURLに合わせて変更してください。
REDIRECT_URL は、AndroidManifest.xml で指定した値に合わせてください。

その他ユーティリティも載せておきます。

```java:ProgressAsyncTaskManager.java
package パッケージ名;

import android.app.Activity;
import android.app.ProgressDialog;
import android.content.DialogInterface;
import android.content.DialogInterface.OnClickListener;
import android.os.AsyncTask;

public class ProgressAsyncTaskManager extends AsyncTask<Object, Void, Object> implements OnClickListener
{
    Callback callback;
    Activity activity;
    boolean isCancel = false;
    ProgressDialog dialog = null;
    String message = null;

    public static abstract class Callback
    {
        ProgressAsyncTaskManager manager = null;

        public Callback( Activity activity, String message, Object inputObj )
        {
            manager = new ProgressAsyncTaskManager( this, activity, message );
            manager.execute( inputObj );
        }

        public void doCancelExecute(){
        };

        public abstract Object doInBackground( Object inputObj ) throws Exception;
        public abstract void doPostExecute( Object outputObj );
    }

    public ProgressAsyncTaskManager( Callback callback, Activity activity, String message )
    {
        this.callback = callback;
        this.activity = activity;
        this.message = message;
    }

    @Override
    protected void onPreExecute()
    {
        if( message != null )
        {
            dialog = new ProgressDialog( activity );
            dialog.setMessage( message );
            dialog.setProgressStyle(ProgressDialog.STYLE_SPINNER);
            dialog.setButton( ProgressDialog.BUTTON_NEGATIVE, "キャンセル", this );
            dialog.setCancelable( false );
            dialog.show();
        }
    }

    @Override
    protected Object doInBackground( Object... objs )
    {
        try{
            return callback.doInBackground( objs[0] );
        }catch( Exception ex )
        {
            return ex;
        }
    }

    @Override
    protected void onCancelled( Object obj )
    {
        if( dialog != null )
            dialog.dismiss();
        callback.doPostExecute( obj );
    }

    @Override
    protected void onPostExecute( Object obj )
    {
        if( dialog != null )
            dialog.dismiss();
        callback.doPostExecute( obj );
    }

    @Override
    public void onClick(DialogInterface dialog, int which )
    {
        isCancel = true;
        try{
            callback.doCancelExecute();
        }catch(Exception ex){}
        cancel( true );
    }
}
```

```java:HttpPostJson.java
package パッケージ名;

import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.DataOutputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Map;
import javax.net.ssl.HttpsURLConnection;
import java.net.URLEncoder;
import java.util.List;

public class HttpPostJson {
    public static JSONObject doPost_getToken(String requestUrl, final Map<String, String> headerParams, final Map<String, String> bodyParams) throws Exception {
        if (requestUrl == null || bodyParams == null || bodyParams.size() < 1 ) {
            throw new Exception("Invalid http request parameters");
        }

        URL uri = new URL(requestUrl);

        final HttpsURLConnection httpsURLConnection = (HttpsURLConnection) uri.openConnection();
        DataOutputStream httpOutputStream = null;
        BufferedReader br = null;
        try {
            // Request header
            httpsURLConnection.setRequestMethod("POST");
            httpsURLConnection.setDoOutput(true);
            for (Map.Entry<String, String> param: headerParams.entrySet()) {
                httpsURLConnection.addRequestProperty(param.getKey(), param.getValue());
            }

            // Request body
            StringBuilder reqBuilder = new StringBuilder();
            int index = bodyParams.size();
            for (Map.Entry<String, String> param: bodyParams.entrySet()) {
                reqBuilder.append(URLEncoder.encode(param.getKey(), "UTF-8")).append('=').append(URLEncoder.encode(param.getValue(), "UTF-8"));
                if(index-- > 1){
                    reqBuilder.append('&');
                }
            }
            String requestBody = reqBuilder.toString();

            httpOutputStream = new DataOutputStream(httpsURLConnection.getOutputStream());

            httpOutputStream.writeBytes(requestBody);
            httpOutputStream.flush();

            // Parse response
            Map<String, List<String>> respHeaders =  httpsURLConnection.getHeaderFields();

            int responseCode = httpsURLConnection.getResponseCode();

            if (responseCode >= HttpURLConnection.HTTP_OK &&
                    responseCode < HttpURLConnection.HTTP_INTERNAL_ERROR) {

                // Return response from the http call
                InputStream httpDataStream;
                if (responseCode < HttpURLConnection.HTTP_BAD_REQUEST) {
                    httpDataStream = httpsURLConnection.getInputStream();
                } else {
                    httpDataStream = httpsURLConnection.getErrorStream();
                }
                br = new BufferedReader(new InputStreamReader(httpDataStream));

                String line = "";
                StringBuilder responseOutput = new StringBuilder();
                while ((line = br.readLine()) != null) {
                    responseOutput.append(line);
                }

                return new JSONObject(responseOutput.toString());
            } else {
                // Throw a Cognito Client Exception
                throw new Exception(httpsURLConnection.getResponseMessage());
            }
        } catch (final Exception e) {
            throw e;
        } finally {
            if (httpOutputStream != null) {
                httpOutputStream.close();
            }
            if (br != null) {
                br.close();
            }
        }
    }
}
```

こちらを参考にさせていただきました。
　[aws-amplify/aws-sdk-android](https://github.com/aws-amplify/aws-sdk-android/blob/master/aws-android-sdk-cognitoauth/src/main/java/com/amazonaws/mobileconnectors/cognitoauth/util/AuthHttpClient.java )

ProgressAsyncTaskManagerはネットワーク通信を非同期に行うためのものであって、これを使うのは必須ではありません。

参考までに、LayoutXMLも示しておきます。

```xml:activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="ClientID" />
    <EditText
        android:id="@+id/edit_client_id"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="XXXXXXXXXXXXXXXXXXXXXXXXX" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="ClientSecret" />
    <EditText
        android:id="@+id/edit_client_secret"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="State" />
    <EditText
        android:id="@+id/edit_state"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="12345678" />

    <Button
        android:id="@+id/btn_chromtab"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Chrome Custom Tab" />
    <Button
        android:id="@+id/btn_browser"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Browser" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="State" />
    <TextView
        android:id="@+id/txt_state"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Code" />
    <TextView
        android:id="@+id/txt_code"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

    <Button
        android:id="@+id/btn_token"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="getToken" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="IdToken" />
    <TextView
        android:id="@+id/txt_id_token"
        android:maxLines="3"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="AccessToken" />
    <TextView
        android:id="@+id/txt_access_token"
        android:maxLines="3"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="RefreshToken" />
    <TextView
        android:id="@+id/txt_refresh_token"
        android:maxLines="3"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />
</LinearLayout>
```

# 補足

* public void onClick(View view)

ボタンを押下したときに起動する処理です。

R.id.btn_browser
　「ブラウザ」ボタンを押下した場合の処理です。ブラウザを起動させ、サインインし、結果として認可コードを取得します。

R.id.btn_chromtab
「Chrome Custom Tabs」ボタンを押下した場合の処理です。Chrome Custom Tabsを起動させ、サインインし、結果として認可コードを取得します。

R.id.btn_token
取得した認可コードを使って、Cognitoのトークンエンドポイントにアクセスして、トークンを取得します。

* private Uri makeAuthroseUri(String client_id, String redirect_uri, String state)
ブラウザまたはChrome Custom Tabsにサインインを要求する際に指定するURLを生成します。

* private Map<String, String> generateTokenExchangeHeader(String client_id, String client_secret)
トークンエンドポイントに対して、トークンを取得する際のリクエストヘッダを生成します。アプリクライアントIDとシークレットをBase64エンコードしています。

* private Map<String, String> generateTokenExchangeRequest(String client_id, String redirect_uri, String code)
トークンエンドポイントに対して、トークンを取得する際のリクエストボディに含めるパラメータを生成しています。

* public void onResume()

ブラウザまたはChrome Custom Tabsから戻り値である認可コードを戻してくれるのですが、それを受け取る口がここです。
アクションが「Intent.ACTION_VIEW」だった場合に、「intent.getData()」で戻り値を取得できます。
認可コードに加えて、stateもチェックしていますが、ブラウザまたはChrome Custom Tabsにサインインを要求した際に指定したstateパラメータと同じであるはずです。

#動作確認

Androidネイティブアプリを起動するとこんな感じで表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/8323bf70-a1ce-6d01-2440-0e29f1e1fe7a.png)

Chrome Custom Tabsボタンを押下すると、見慣れたサインイン画面が表示されます。Cognitoで設定している有効なIDプロバイダによって選択肢は変わります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/57f7ea3a-f1e7-1908-3cd7-462f12c814c8.png)

以下は、Googleログインを選択した場合です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/0f238708-7fcf-72bb-d992-1a0c97285d98.png)

サインインに成功すると、認可コードが表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/4364e9fd-6157-029d-708e-30b3eb0cae3a.png)

さらに、GetTokenボタンを押下すると、トークンの取得が完了します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/be335f28-f696-7557-e036-53f30bcba27e.png)

ちなみに、Chrome Custom Tabsではなく、ブラウザでサインインしようとした場合は以下の感じです。Chrome Custum Tabsとほぼ同じ見栄えですが、Androidネイティブアプリとは別インスタンスになります。以降はChrome Custum Tabsの場合と同じ流れです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/742c2425-e605-ac65-85af-f94bd0631db7.png)

以上です。