こんにちは、初めまして。 入社して3ヶ月経ち、家族向けアプリの開発に勤しむ中、最近、自分にも家族(子猫×2)ができたAndroidエンジニアのTsutouです。
AndroidでApple IDでログイン?
さて、弊社の事業であるFammでは、ログイン時のUX改善として、Sign in with Appleを導入しました。
背景として、Sign in with Apple(Apple ID でのログイン)はメールアドレスの秘匿によるプライバシー保護観点、ログインが簡単になるという点でとても良い機能です。
iOSではサードパーティログインを提供しているアプリにおいて実装が必須となっていること、昨今ではiPhoneからAndroidに乗り換えるユーザーも一定数いることから、両OS同時の導入を決定しました。
しかし、AndroidでSign in with Appleするには、iOSとは違い公式のサポートSDKなどが今の所提供されていないので、その他のプラットフォームの文脈で実装するしかなさそうです。
はじめに
今回は、Appleのドキュメント、 Incorporating Sign in with Apple into Other Platforms に従い、ミニマムに実装していきます。
流れとしては、
- AppleのWebページ上で認証
- クライアント側で情報を受け取る
- その情報を自社のサーバーで認証
という具合になります。
なので、WebViewで実装していきます。 Sign In用のボタンからActivityを起動し、onActivityResultで結果を受け取るイメージです。
こんな具合の認証フローになります
認証URLの生成
認証ページに行くためのURLを生成します。
ここではRequiredなパラメータのみ紹介します。
- response_type
- 要求するレスポンスタイプです。
code
を受け取るか、id_token
を受け取るか、または両方受け取るか選択できます。
- 要求するレスポンスタイプです。
- client_id
- クライアント識別子。Apple Developer Console上から、 Certificates, Identifiers & Profiles で登録したSERVICE ID(Identifier)を入れます。
- redirect_uri
- state
- 後でレスポンスとの照合に使います。
- response_mode
id_token
を受け取りたい場合は、fragment
かform_post
を指定する必要があります。今回はfragment
で受け取ります。
- scope(※ 任意)
- 名前やメールアドレスを受け取りたい場合は、こちらの指定をし、response modeを
form_post
に指定してください。(今回はサインインのみなので使用しません)"name"
or"email"
or"name email"
- 名前やメールアドレスを受け取りたい場合は、こちらの指定をし、response modeを
private const val APPLE_ID_DOMAIN = "appleid.apple.com" private const val AUTHORIZATION_URI = "https://appleid.apple.com/auth/authorize" private val state = UUID.randomUUID().toString()
val authenticationUri = Uri .parse(AUTHORIZATION_URI) .buildUpon().apply { appendQueryParameter("response_type", "code id_token") appendQueryParameter("client_id", BuildConfig.SIGN_IN_WITH_APPLE_IDENTIFER) appendQueryParameter("redirect_uri", BuildConfig.SIGN_IN_WITH_APPLE_REDIRECT_URI) appendQueryParameter("state", state) appendQueryParameter("response_mode", "fragment") } .build() .toString()
状態の管理
Appleから認証してもらい、帰ってきた情報を自社のサーバーに投げ、受け取るまでの状態を、Sealed Classで表現します。独自のパラメータと網羅性を持てるので、とても便利です。
ここでは、
- Appleの認証成功 -> Success
- サーバーの認証成功 -> Complete
と、表現することにします。
sealed class Result { object Complete : Result() data class Success(val idToken: String) : Result() data class Failure(val error: Throwable) : Result() object Cancel : Result() object Loading : Result() }
状態管理用のLiveDataを用意します。
val result: MutableLiveData<Result> = MutableLiveData()
Viewでは上記を元に、ユーザーの状態によって、以下のように処理してあげれば良さそうです。
完了時にパラメーターを渡したい場合は、Result.Complete
を任意のdata class
に変えてsetResult
してあげれば良さそうです。
viewModel.result.observe(this, Observer { result -> if (result != null) { when (result) { is SignInWithAppleViewModel.Result.Complete -> { // 必要に応じてここでログイン処理、またはログインに必要な情報を元の画面に返してあげます。 setResult(Activity.RESULT_OK) finish() } is SignInWithAppleViewModel.Result.Success -> viewModel.loginWithAppleToken(this, result.idToken) is SignInWithAppleViewModel.Result.Failure -> { Timber.d("Received error from Apple Sign In %s", result.error.message) setResult(result.error.code) finish() } is SignInWithAppleViewModel.Result.Cancel -> { Timber.d("Apple Sign In Canceled") setResult(Activity.RESULT_CANCELED) finish() } } } })
WebViewのクライアント設定
WebViewに各処理のコールバックを設定してあげます。Appleドメインのもの、または指定したリダイレクト先しかオーバーライドしない用に指定します。 リダイレクト先の場合、レスポンスに応じ、Resultをセットしてあげましょう。
webView.webViewClient = object : WebViewClient() { // for API levels < 24 override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean { return shouldUrlOverride(view, Uri.parse(url)) } @RequiresApi(Build.VERSION_CODES.N) override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { return shouldUrlOverride(view, request?.url) } fun shouldUrlOverride(view: WebView?, url: Uri?): Boolean { return when { url == null -> { false } url.toString().contains(APPLE_ID_DOMAIN) -> { view?.loadUrl(url.toString()) true } url.toString().contains(BuildConfig.SIGN_IN_WITH_APPLE_REDIRECT_URI) -> { viewModel.setSignInAppleResult(state, url) true } else -> { false } } } }
レスポンスの加工
正常なURLのFragment部分をMapに加工します。
変換したMapから、id_token
と、state
を抽出し、先ほどのResultオブジェクトに乗せて返してあげます。
fun createFragmentMap(url: Uri): Map<String, String>? { return url.fragment?.split("&")?.map { it.split("=") }?.associate { it[0] to it[1] } }
fun parseSignInStateFromUrlFragment(state : String, uri: Uri): Result { val urlFragmentMap = createFragmentMap(uri) ?: return Result.Failure(IllegalArgumentException("fragment not returned")) val idToken = urlFragmentMap["id_token"] val stateParameter = urlFragmentMap["state"] return when { idToken == null -> { Result.Failure(IllegalArgumentException("idToken not returned")) } stateParameter != state -> { Result.Failure(IllegalArgumentException("state does not match")) } else -> { Result.Success(idToken) } } }
fun setSignInAppleResult(state: String, uri: Uri) {
result.value = parseSignInStateFromUrlFragment(uri, resultMap)
}
Apple IDで認証後、サーバーとの連携
Appleから情報を受け取って、サーバーにログインします。id_token
はJWT(JSON Web Token)形式で、指定したリダイレクト先に帰って来ます。
implementation 'com.auth0.android:jwtdecode:1.4.0'
fun loginWithAppleToken(context: Context, idToken: String) { viewModelScope.launch(Dispatchers.Default) { result.postValue(Result.Loading) //サーバー側にjwt形式で取得できたidTokenを投げ、Verify / ログインします val loginResult = repository.login(context, appleToken) if (loginResult.isSuccessful) { result.postValue(Result.Complete) } else { result.postValue(Result.Failure(Exception("login Failed"))) } } }
以上です。
id_token
について
id_token
の中身を参照してみたい場合、下記のようなライブラリで中身を確認できます。(※Java用のモノだと端末によってうまく動かない事があります)
/* *{ * "iss": "https://appleid.apple.com", * "aud": "{SERVICE ID}", * "exp": 1574228621, * "iat": 1574228021, * "sub": "{appleUserID}", * "c_hash": "{c_hash}", * "email": "tsutoutakehara@gmail.com", * "email_verified": "true", * "auth_time": 1574228021 * } */ val decodeToken = try { JWT(idToken) } catch (e: DecodeException) { e.printStackTrace() } val aud = decodeToken.audience val sub = decodeToken.subject val email = decodeToken.getClaim("email")
まとめ
これでAndroidでもApple IDでログインできるようになります。 今後iOSで導入が増えていくことは確実ですし、最近はiPhoneからAndroidに乗り換える人もどんどん増えているようなので、Androidでも導入できてるといいですよね。
ここまでお読みいただき、ありがとうございました!
積極採用中!!
子育て家族アプリFammを運営するTimers inc.では、現在エンジニアを積極採用中!
急成長中のサービスの技術の話を少しでも聞いてみたい方、スタートアップで働きたい方など、是非お気軽にご連絡ください!
採用HP: http://timers-inc.com/engineerings