Timers Tech Blog

グローバルな家族アプリFammを運営するTimers inc (タイマーズ) の公式Tech Blogです。弊社のエンジニアリングを支える記事を随時公開。エンジニア絶賛採用中!→ https://timers-inc.com/engineering

AndroidでもSign in with Appleしたい!#Android #Kotlin #sia #SigninwithApple

こんにちは、初めまして。 入社して3ヶ月経ち、家族向けアプリの開発に勤しむ中、最近、自分にも家族(子猫×2)ができたAndroidエンジニアのTsutouです。

AndroidApple IDでログイン?

さて、弊社の事業であるFammでは、ログイン時のUX改善として、Sign in with Appleを導入しました。

背景として、Sign in with Apple(Apple ID でのログイン)はメールアドレスの秘匿によるプライバシー保護観点、ログインが簡単になるという点でとても良い機能です。
iOSではサードパーティログインを提供しているアプリにおいて実装が必須となっていること、昨今ではiPhoneからAndroidに乗り換えるユーザーも一定数いることから、両OS同時の導入を決定しました。

しかし、AndroidSign in with Appleするには、iOSとは違い公式のサポートSDKなどが今の所提供されていないので、その他のプラットフォームの文脈で実装するしかなさそうです。

はじめに

今回は、Appleのドキュメント、 Incorporating Sign in with Apple into Other Platforms に従い、ミニマムに実装していきます。

流れとしては、

  1. AppleのWebページ上で認証
  2. クライアント側で情報を受け取る
  3. その情報を自社のサーバーで認証

という具合になります。

なので、WebViewで実装していきます。 Sign In用のボタンからActivityを起動し、onActivityResultで結果を受け取るイメージです。

こんな具合の認証フローになります

button-sign-in-with-appleemail-screen-sign-in-with-appletwo-factor-screen-sign-in-with-appleconfirm-screen-sign-in-with-apple
Sign in with Apple認証フロー

認証URLの生成

認証ページに行くためのURLを生成します。

ここではRequiredなパラメータのみ紹介します。

  • response_type
    • 要求するレスポンスタイプです。codeを受け取るか、id_tokenを受け取るか、または両方受け取るか選択できます。
  • client_id
  • redirect_uri
    • Appleに認証された後、コントロールがクライアント側に戻った際、情報を受け取るためにリダイレクトされるURLです。queryfragment、またはbodyに付与される形でレスポンスを受け取ります。
  • state
    • 後でレスポンスとの照合に使います。
  • response_mode
    • id_tokenを受け取りたい場合は、fragmentform_postを指定する必要があります。今回はfragmentで受け取ります。
  • scope(※ 任意)
    • 名前やメールアドレスを受け取りたい場合は、こちらの指定をし、response modeform_postに指定してください。(今回はサインインのみなので使用しません) "name" or "email" or "name email"
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_tokenJWT(JSON Web Token)形式で指定したリダイレクト先に帰ってくる情報の中に、subとして含まれています。

(実運用の際は、id_token単体で認証するのではなく、クライアント固有の情報を含め、サーバーと連携して認証しましょう)

ここではデコードするために、ライブラリを使用します。Java用のモノだとデバイスによってエラーになるのでAndroid用のモノを使ってください。

implementation 'com.auth0.android:jwtdecode:1.4.0'
fun loginWithAppleToken(context: Context, jwtToken: String) {

    viewModelScope.launch(Dispatchers.Default) {
        result.postValue(Result.Loading)

        //JWT形式でAppleからTokenが帰ってくるので、デコードします
        val decodeToken = try {
            JWT(jwtToken)
        } catch (exception: DecodeException) {
            result.postValue(Result.Failure(exception))
            null
        } ?: return@launch

        //サーバー側にidTokenを送信します
        val appleToken = decodeToken.subject

        if (appleToken == null) {
            result.postValue(Result.Failure(Exception("token must not be null")))
            return@launch
        }

        //自社サーバーにログインする
        val loginResult = repository.login(context, appleToken)

        if (loginResult.isSuccessful) {
            result.postValue(Result.Complete)
        } else {
            result.postValue(Result.Failure(Exception("login Failed")))
        }
    }
}

以上です。

まとめ

これでAndroidでもApple IDでログインできるようになります。 今後iOSで導入が増えていくことは確実ですし、最近はiPhoneからAndroidに乗り換える人もどんどん増えているようなので、Androidでも導入できてるといいですよね。

ここまでお読みいただき、ありがとうございました!

積極採用中!!

子育て家族アプリFammを運営するTimers inc.では、現在エンジニアを積極採用中!
急成長中のサービスの技術の話を少しでも聞いてみたい方、スタートアップで働きたい方など、是非お気軽にご連絡ください!
採用HP: http://timers-inc.com/engineerings

Timersでは各職種を積極採用中!

急成長スタートアップで、最高のものづくりをしよう。

募集の詳細をみる