見出し画像

アプリからの AWS Cognito 認証をシンプルに実現する方法

こんにちは、ホンビノス五郎です。
ナビタイムジャパンで『ビジネスナビタイム動態管理ソリューション』のAndroidアプリ開発を担当しています。

当社では毎年社員旅行を開催しており、そのための「社員旅行アプリ」も自社で作成しています。このアプリでは、旅程や新幹線・バスの座席表を確認することができます。今年も、2019年に作成した際と同じコンセプトでアプリを制作しました。
※社員旅行アプリのコンセプトについては、以前Qiita記事で紹介しています。

今回の社員旅行アプリ開発にあたって、 AWS Cognito を用いてバックエンドへのリクエストを認証する仕組みを構築したため、その方法について説明します。

なお、筆者は主にAndroidアプリの開発を行っているため、具体的な実装についてはAndroidアプリに関する内容を中心に解説します。ただし、OS特有の機能はあまり使用していないため、iOSでの実装にも参考になる部分が多いかと思います。

本記事で説明すること・しないことは以下の通りです。

  • 説明すること

    • Cognito と API Gateway の設定方法

    • Android における大まかな実装方法

  • 説明しないこと

    • 利用する AWS サービスの役割や機能について

    • AWS サービスの設定における各種設定値の内容について

    • 実際にAndroidアプリを開発する上での適切な設計について

    • iOS における実装について


経緯

社員旅行アプリで使うバックエンドを構築する上で、社員ごとにアカウントを作り、バックエンドへのリクエストを制限する必要がありました。

バックエンド本体は API Gateway と Lambda を使ってサーバレスで作ることが決まっていたため、 API Gateway と組み合わせて簡単に認証の仕組みを実現できる AWS Cognito でアカウント管理をすることにしました。

構成

今回作成したバックエンドは大まかに次のような構成になっています。
(本記事で説明しない部分は省略しています。)

構成図

① IDとパスワード で Amazon Cognito にログイン
② トークンを受け取る
③ トークンをヘッダに含めリクエスト
④ Cognito をオーソライザとしてトークンを確認
⑤ 認可

なお、 API Gatway と Lambda については SAM で構築しています。

前提

ログインUIについては、 Cognito のホストされたUIをそのまま利用しました。理由は次の2点です。

  • 社内向けのアプリなので、利用開始時でしか触らないログイン部分でUXを向上させる意義はあまりないため

  • Amplify を使うための学習コストをかけたくなかったため

    • 筆者が TypeScript に疎いため

    • iOS側の開発はiOS担当者にやってもらう必要があったため

バックエンド設定方法

Cognito ユーザープール設定

AWS コンソールから、ユーザープールを作成します。

設定方法については、以下の記事で画像付きで説明されているため、参考にしてみてください。

設定内容については、プロダクトの要件によって決める必要がありますが、ここでは4つの設定について補足します。

自己登録を有効化
ログイン画面からユーザー登録を行えるようにするかどうかの設定です。
これを有効にすると、ログイン画面にアクセスすれば誰でもアカウントを作成できるようになります。
今回は利用する社員のアカウントをあらかじめ作成し、それらのみ使えるようにしたかったので、無効にしました。

Cognito のホストされた UI を使用
これを有効にすることで、ホストされたUIを使うことができます。

許可されているコールバック URL
ログイン成功時にリダイレクトするURLです。
アプリを開くディープリンクを設定することで、ログイン完了後にアプリに戻ることができます。
https が最初から入力されていますが、他のスキーマにすることもできます。

許可されているサインアウト URL
「高度なアプリケーションクライアントの設定」の中にあります。
ログアウト成功時にリダイレクトするURLです。
基本的にログイン用のURLと同じです。
なくても認証はできますが、ログアウト機能を実装したい場合は設定しておくことをおすすめします。

ユーザープールを作成したら、「アプリケーションの統合」タブから作成したアプリケーションクライアントを選択し、「ホストされた UI を表示」を押すと、ログインページが開きます。
このログインページのURLを、アプリでもそのまま使用します。

API Gateway オーソライザー設定

SAM を利用する場合、 template.yaml に次のような設定を記載することで、 API Gateway のオーソライザーとして Cognito を設定できます。

Resources:
  MyApi:
    Type: AWS::Serverless::Api
    Properties:
      ...
      Auth:
        Authorizers:
          CognitoAuthorizer:
            UserPoolArn: 作成したユーザープールのARN
        DefaultAuthorizer: CognitoAuthorizer

Androidアプリでの実装方法

1. URLをブラウザで開く

先述したログインページのURLをブラウザアプリで開きます。

val url = "ログインページのURL"
val intent = Intent(Intent.ACTION_VIEW, url)
context.startActivity(intent)

Chrome Custom Tabs を使うとディープリンクへのリダイレクトをうまく処理できないため、 Intent を使ってブラウザで開かせます。

2. ディープリンクでの Intent を受け取る

intent-filter を使って、ログイン成功時のリダイレクトURLからアプリが開かれるよう設定します。

<activity ...>
    ...
    <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="リダイレクトURLのスキーマ" />
        <data android:host="リダイレクトURLのホスト" />
    </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="ログアウト用リダイレクトURLのスキーマ" />
        <data android:host="ログアウト用リダイレクトURLのホスト" />
    </intent-filter>
</activity>
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        val uri = intent?.data
        if (uri?.host == "リダイレクトURLのホスト") {
            // uri を使ってトークン取得
        } else if (uri?.host == "ログアウト用リダイレクトURLのホスト") {
            // 保存したトークンを削除
        }
    }
}

3. 認証コードを使ってトークンを取得する

ログイン後リダイレクトされるURLには、 code というクエリパラメータで認証コードが含まれています。

これを使って、Cognito のトークン取得API (/oauth2/token) にリクエストすることで、認証に使えるトークンを取得できます。

APIの仕様については、公式ドキュメントに記載されています。
(機械翻訳のため少し読みづらいかもしれません。)

ここでは、 Retrofit を使った実装例を示します。

val client = OkHttpClient.Builder().build()
val retrofit = Retrofit.Builder()
    .baseUrl("ユーザープール作成時に指定したドメイン"))
    .addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
    .client(client)
    .build()

suspend fun getToken(redirectUri: Uri): Response<TokenResponse>? {
    val authCode = redirectUri.getQueryParameter("code") ?: return null
    val api = retrofit.create(OAuth2Api::class.java)
    return api.token(authCode, redirectUri.toString())
}

suspend fun refreshToken(refreshToken: String): Response<RefreshTokenResponse> {
    val api = retrofit.create(OAuth2Api::class.java)
    return api.refreshToken(refreshToken)
}

interface OAuth2Api {
    @FormUrlEncoded
    @POST("/oauth2/token")
    suspend fun token(
        @Field("code") code: String,
        @Field("redirect_uri") redirectUri: String,
        @Field("grant_type") grantType: String = "authorization_code",
        @Field("client_id") clientId: String = CLIENT_ID
    ): Response<TokenResponse>

    @FormUrlEncoded
    @POST("/oauth2/token")
    suspend fun refreshToken(
        @Field("refresh_token") code: String,
        @Field("grant_type") grantType: String = "refresh_token",
        @Field("client_id") clientId: String = CLIENT_ID
    ): Response<RefreshTokenResponse>

    companion object {
        const val CLIENT_ID = "CognitoアプリケーションクライアントのクライアントID"
    }
}

@Serializable
data class TokenResponse(
    @SerialName("access_token")
    val accessToken: String,
    @SerialName("id_token")
    val idToken: String,
    @SerialName("refresh_token")
    val refreshToken: String,
    @SerialName("token_type")
    val tokenType: String,
    @SerialName("expires_in")
    val expiresIn: Int
)

@Serializable
data class RefreshTokenResponse(
    @SerialName("access_token")
    val accessToken: String,
    @SerialName("id_token")
    val idToken: String,
    @SerialName("token_type")
    val tokenType: String,
    @SerialName("expires_in")
    val expiresIn: Int
)

TokenResponse に含まれるトークン情報のうち、 idToken と refreshToken をローカルに保存して、通信に利用します。
それぞれの用途は次のとおりです。

  • idToken: 認証情報としてリクエストヘッダに含めるIDトークン

  • refreshToken: IDトークンの有効期限が切れた時に再取得するために使うリフレッシュトークン

4. 保存したトークンを使ってリクエストをする

先述の方法で取得したIDトークンをリクエストヘッダに次のような形で含めることで、Cognito での認証を通すことができます。

Authorization: Bearer ${取得したIDトークン}

スキームについては基本的に Bearer で問題ないですが、より確実に実装するなら TokenResponse の tokenType を使えます。

Retrofit や Okhttp3 を使っている場合は、次のような Interceptor を使うことで、認証を共通化できます。

class AuthTokenInterceptor(
    private val context: Context,
    private val oAuth2Api: OAuth2Api
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val originalRequest = chain.request()
        val idToken: String = "保存したIDトークン"
        val authorizedRequest = originalRequest.newBuilder()
            .header("Authorization", "Bearer $idToken")
            .build()
        val response = chain.proceed(authorizedRequest)
        if (response.code == HttpsURLConnection.HTTP_UNAUTHORIZED) {
            val newTokens = runBlocking { callRefreshTokenApi() } ?: return response
            val newRequest = originalRequest.newBuilder()
                .header("Authorization", "Bearer ${newTokens.idToken}")
                .build()
            response.close()
            return chain.proceed(newRequest)
        }
        return response
    }

    private suspend fun callRefreshTokenApi(): RefreshTokenResponse? {
        val refreshToken = "保存したリフレッシュトークン" ?: return null
        val response = oAuth2Api.refreshToken(refreshToken)
        val tokens = response.body() ?: return null
        // IDトークンをローカルに保存
        return tokens
    }
}

IDトークンの期限が切れると API Gateway は401エラーを返却します。
そのためこの Interceptor では、401エラーの場合リフレッシュトークンを使って有効なIDトークンを取得しなおした上で、一度だけ再リクエストする仕組みになっています。

おわりに

ホストされたUIとトークンエンドポイントを使うことで、 Cognito による認証が必要なバックエンドリクエストを特殊なライブラリやAPIを使わずにできるようになりました。
しかし、今回行なった実装ではログインページが閉じられずブラウザ上に残ってしまうなどの問題もあり、サービスに導入するためには細かいUXのブラッシュアップが必要だと考えています。

この記事がみなさまのサービス開発に役立てば幸いです。