スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

AndroidアプリでApple・Google・Microsoftのシングルサインオンを実装した話

はじめに

こんにちは。『スタディサプリ』 Android開発チームの@moraylです。

『スタディサプリ 小学/中学/高校/大学受験講座』では昨年、Web/iOS/Androidでシングルサインオン(以下SSO)を実装しました。
学校で利用しているユーザー向けで、ユーザーはApple/Google/Microsoftアカウントのうち学校に許可されたものを紐づけることで、各アカウントでログインできるようになります。
今回は、Androidアプリにて各機能を実装したときの話をし、SSOについて得た知見を記せればと思っています。

シーケンス図が出てきますが、Androidアプリにフォーカスするため、API以降の動作は省略したり簡易的に説明したりしています。
設定ファイルやソースコードはサンプルを用いて解説します。
文中の記載は実装当時のものなので、参考の際には最新の公式ドキュメントも合わせてご確認ください。

全体方針

SSOにはいくつか実装方法がありますが、社内のセキュリティ基準を鑑みて、以下の2パターンで行うことになりました。

  • クライアント向けの公式ライブラリを使用する
  • 自前で実装する

また、各ID Providerにログインを行う際には、WebViewではなく外部ブラウザを利用することにしました。 今回SSO実現のためにOpenID Connectを利用しますが、ネイティブアプリにおいて、そこで使われるOAuth 2.0認証は外部ブラウザで行うべきだからです。

国際標準であるRFC8252(Best Current Practice)では、

OAuth 2.0 authorization requests from native apps should only be made through external user-agents, primarily the user's browser.

と記されており、セキュリティやUX・互換性の観点から、ユーザーが利用するブラウザを使うべきと記載があります。

また、GoogleのFAQにも、

お客様のアプリの中に、認証に WebView を使用するものが含まれていますが、この方法は推奨されません。OAuth 2.0 リクエストに WebView を使用すると、アプリのセキュリティとユーザビリティの両方に悪影響を与えます。

とあり、認証にWebViewを使用することは非推奨との記載があります。

Apple

外部ブラウザを立ち上げて認証する方式を採用しました。
Apple公式のAndroid向けライブラリが存在せず、ドキュメントにも

To add Sign in with Apple to apps that don’t have access to the Sign in with Apple JS framework, you control the sign-in request manually.

=「Sign in with Apple JSを使わない場合にはサインインリクエストを手動で制御する」とあります。

画面遷移

ブラウザでAppleログインを開き、ログインが完了するとアプリが立ち上がり、プロフィール画面が開かれます。

1.プロフィール画面でAppleを押す 2.Appleログインのサイトが開く 3.信頼するを押す
プロフィール画面でAppleを押す Appleログインのサイトが開く 信頼するを押す
4.続けるを押す 5.Appleが連携済みになる
同意画面 プロフィール画面で連携済みになる

シーケンス

アプリにログインしているユーザーが、SSOを利用するために紐づけを行うシーケンスを説明します。

大まかな流れとしては、以下になります。

  1. アプリからAPIを使ってnonceを発行
  2. ブラウザでApple認証を行う
  3. ブラウザからアプリが起動される
  4. アプリからAPIを使って紐づけを依頼(APIはnonceを検証する)

以下でシーケンス図を使って詳しく説明します。

紐づけボタンを押す

シーケンス図

ログイン済みのユーザーは、プロフィール画面でApple SSOの紐づけを行うことができます。
紐づけの際、nonceを使っています。

nonceとは、認証などの際に用いられる使い捨てのランダムなデータです。ユーザーの同一性を担保し、一度使ったら無効化することで、リプレイ攻撃やコードインジェクション攻撃を防ぐことができます。
nonceを利用した場合、ID Tokenにはnonceが含まれるようになり、発行者は発行したnonceとID Tokenに含まれるnonceを検証することが出来ます。

外部ブラウザを介す場合は「他人が発行したnonceが使われたカスタムURLスキーム」を受け取る可能性があります。要求したnonceをアプリに保存し、カスタムURLスキームを受け取った後にID Tokenと共にアプリに保存したnonceをAPIに送ることで、自分が発行したものだということを検証出来ます。
そのため本シーケンスでは、要求したnonceをアプリに保存します。

Apple認証

シーケンス図

nonceを保存した後、アプリはブラウザでAppleのログイン画面を開きます。
URLとパラメーターはドキュメントに記載されています。 今回は、以下のパラメーターともに認証画面を開きます。

パラメーター 説明 指定内容
client_id clientの識別子 アプリで定義したID
redirect_uri Apple認証が完了したときにAppleから叩かれるuri 『スタディサプリ』のWebページを指定
state フローの開始からコールバックまで任意の値を引き渡す仕組み クライアント種別などのメタ情報を乗せる
nonce クライアントセッションと ID Tokenをひもづける値 APIからもらったnonceを指定
response_type 認証タイプ Authorization Code Flowなので"code"を指定
response_mode 認証完了後にどのようなレスポンスを返すか "form_post"

ここで注意が必要なのがredirect_uriです。
当初、Apple認証完了後にブラウザからアプリを開こうと考えていて、アプリのカスタムURLスキームを指定しようと思っていたのですが、APIドキュメントには以下のように書かれています。

The URI must use the HTTPS protocol, include a domain name, can’t be an IP address or localhost, and must not contain a fragment identifier (#).

app://などのアプリを開くようなURLは設定できず、httpsのみが許可されていました。
Android App Linksを使用していれば、httpsをアプリで拾って起動できますが、当アプリは未対応のため、一度専用のWebページを開いてそこからカスタムURLスキームを使ってアプリを起動することにしました。

認証完了後

シーケンス図

アプリ向けの専用ページからアプリが開かれると、stateとcodeが渡ってくるので、紐づけAPIにローカル保存しているnonceと合わせて紐づけAPIを叩きます。
その後API側で認証やセキュリティチェックを行い、問題なければアプリに結果を返して、成功ならアプリで紐づけ済みの表示にします。

紐づけ後のログインについては、ほぼ同じ流れで、最後にAPIからID Tokenを返してもらい、それを元にログイン処理を行うため省略します。

Google

Android公式ライブラリであるCredential ManagerとGoogle IDを使うことにしました。

画面遷移

Googleログインのダイアログが開き、アカウントを選択すると、プロフィール画面に戻ります。

1.プロフィール画面でGoogleを押す 2.アカウントを選択する
プロフィール画面でGoogleを押す アカウント選択画面
3.同意する 4.連携済みになる
同意画面 プロフィール画面で連携済みになる

シーケンス

アプリにログインしているユーザーが、SSOを利用するために紐づけを行うシーケンスを説明します。
大まかな流れとしては、以下になります。

  1. アプリからAPIを使ってnonceを発行
  2. Credential ManagerでGoogle認証を行う
  3. アプリからAPIを使って紐づけを依頼

以下でシーケンス図を使って詳しく説明します。

紐づけボタンを押す

シーケンス図

Apple SSOと同様に、ログイン済みのユーザーは、プロフィール画面で紐づけを行うことができます。 nonceを利用するところも同様です。
Google SSOでは、Credential Managerを利用することで外部ブラウザを介さなくなるため、CSRFやコードインジェクションのリスクを低減できます。
それによって、ID Tokenに含まれるnonceをAPIで検証するだけで良いため、nonceをアプリ側に保存していません。

Google認証

シーケンス図

ライブラリが行ってくれる部分です。 認証に成功すると、ID Tokenが入手できます。

認証完了後

シーケンス図

ここからはAppleの際とほとんど同じで、API側で検証や紐づけを行い、結果をアプリに返却します。

実装方法

公式ドキュメントに、Google Cloud Consoleの設定から、Credential Managerの使い方まで詳しく書いてあります。
個人的にポイントと思った部分を解説します。

Google Cloud Consoleの設定

Google Cloud Consoleでは、AndroidアプリとWebのOAuth 2.0 クライアント IDを作成する必要があります。
Androidアプリは、applicationIdSuffixなどでバリアントによってapplicationIdを変えている場合、テストしたいIDごとにクライアントIDを作成する必要があります。

依存関係の追加

Google SSOを実装するには、androidx.credentials:credentialsを含め、以下のライブラリが必要になります。
バージョンは執筆時点のものです。

  • androidx.credentials:credentials:1.5.0
  • androidx.credentials:credentials-play-services-auth:1.5.0
  • com.google.android.libraries.identity.googleid:googleid:1.2.0

Googleログインリクエスト

以下がコード例です。

suspend fun requestGoogleIdToken(activity: Activity, nonce: Nonce): Result<GoogleIdToken> {
    val serverClientId = ...
    val signInWithGoogleOption: GetSignInWithGoogleOption = GetSignInWithGoogleOption.Builder(serverClientId)
        .setNonce(nonce.value)
        .build()

    val request: GetCredentialRequest = GetCredentialRequest.Builder()
        .addCredentialOption(signInWithGoogleOption)
        .build()
    return try {
        val resultCredential = credentialManager.getCredential(activity, request).credential
        if (resultCredential is CustomCredential && resultCredential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) {
            val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(resultCredential.data)
            Result.Success(GoogleIdToken(googleIdTokenCredential.idToken))
        } else {
            Result.Error(IllegalStateException("Credential is not GoogleIdTokenCredential or type mismatch."))
        }
    } catch (e: GetCredentialCancellationException) {
        Result.Error(SsoAuthorizationCancellationException())
    } catch (t: Throwable) {
        Result.Error(t)
    }
}

GetSignInWithGoogleOption.Builder(serverClientId)

GetSignInWithGoogleOptionのインスタンス化の際に、 serverClientIdという引数が必要になります。
これには、Google Cloud Consoleで作ったAndroidアプリのOAuth 2.0 クライアント IDではなく、Webのクライアント IDを指定する必要があります。

GetCredentialRequest.Builder().addCredentialOption(signInWithGoogleOption)

Credential ManagerのGetCredentialRequestにsignInWithGoogleOptionを入れることで、GoogleSSOが実現できます。

GetCredentialCancellationException

ユーザーがバックキーなどで処理をキャンセルした場合、このExceptionがスローされるので、エラーとは別に処理する必要があります。
独自のExceptionに変換しているのは、このアプリがマルチモジュールの設計上、このメソッドを利用するモジュールがCredential Managerに依存しなくなっているからです。

動作確認の際の注意点

Google Cloud ConsoleにapplicationIdと署名情報を入れる関係で、動作確認の際に認証がうまく行かない場合があります。
具体的には「Google Cloud Consoleの署名に何を入れるか」と「アプリをどの環境で確認するのか」の組み合わせに依ります。

動作確認には「ローカルビルド」「DeployGate」「Firebase App Distribution」「Google Play」などがあります。
Firebase App DistributionとGoogle Playの場合は署名が変化する場合があるので、Google Cloud Consoleにどの署名を登録するかによって、確認方法が限られてきます。

例えば、ビルドバリアントとして、debug/rc/releaseの3種類が存在する場合について考えます。
以下のように設定されています。

ビルドバリアント Google Cloud Consoleに登録する署名
debug debug署名
rc rc署名
release Google Play署名

この設定では、debug/rcはローカルやDeployGateで確認できますが、releaseはGoogle Playから配信しないと確認できません。
releaseは実際にユーザーに提供するバリアントなので、Google Play署名にする必要があります。
Google Play署名は、Google PlayConsoleのテストとリリース > アプリの完全性 > アプリの署名 から確認できます。

また、Firebase App Distributionはどれとも異なる署名が付与されるため、SSOの動作確認をする際には適さないと判断しました。

Google Play署名は、Google側で用意されたものではなく自分でアップロードしたものを使っている場合は、ローカルでも同じ署名にできるので動作確認できることになります。

Microsoft

Microsoftが提供しているライブラリを利用することにしました。
Microsoft Authentication Library(MSAL)というものです。

画面遷移

ブラウザでMicrosoftログインを開き、ログインが完了するとアプリが立ち上がり、プロフィール画面が開かれます。

1.プロフィール画面でMicrosoftを押す 2.Microsoftアカウントでログインする(必要に応じて同意画面が出る) 3.連携済みになる
プロフィール画面でMicrosoftを押す ログイン画面 プロフィール画面

シーケンス

アプリにログインしているユーザーが、SSOを利用するために紐づけを行うシーケンスを説明します。
大まかな流れとしては、以下になります。

  1. アプリからAPIを使ってnonceを発行
  2. MSALでMicrosoft認証を行う
  3. アプリからAPIを使って紐づけを依頼

以下でシーケンス図を使って詳しく説明します。

紐づけボタンを押す

シーケンス図

nonceの発行については、Apple/Googleと同じ流れになります。

Microsoft認証

シーケンス図

ここからがMSALの処理です。 ライブラリで外部ブラウザを開き、戻って来るまでをケアしてくれます。 ライブラリを使うと、メソッドを呼ぶことでMicrosoftのID Tokenを返してくれます。

認証完了後

シーケンス図

ここからはApple/Googleの際とほとんど同じで、API側で検証や紐づけを行い、結果をアプリに返却します。

実装方法

公式ページのチュートリアルが参考になります。
利用するライブラリはmicrosoft-authentication-library-for-androidです。
本記事ではバージョン 8.2.1 を使っています。
更新頻度は低いですが、サンプルアプリのリポジトリも存在します。
ライブラリの説明が書かれたページも参考にしました。

チュートリアルの「構成を追加する」にありますが、AndroidManifestの記述とjsonの配置が必要になります。

jsonの例

{
  "client_id": "00001111-aaaa-bbbb-3333-cccc4444",
  "authorization_user_agent": "BROWSER",
  "redirect_uri": "msauth://com.azuresamples.msalandroidapp/1wIqXSqBj7w%2Bh11ZifsnqwgyKrY%3D",
  "broker_redirect_uri_registered": false,
  "account_mode": "MULTI",
  "authorities": [
    {
      "type": "AAD",
      "authority_url": "https://login.microsoftonline.com/common"
    }
  ]
}

何を設定するかは、Android Microsoft Authentication Library configuration fileに詳しい記載があります。

署名について

特に注意が必要なのが redirect_uri で、 msauth://パッケージ名/base64エンコードされた署名 となります。
公式のよくある質問にも解説があります。
署名はローカルの場合と、Google Play署名の場合の2パターンあったので、それぞれ取得した方法を紹介します。

ローカル

keystoreのSHA-1署名を取得します。

keytool -list -v -alias alias -keystore sample.keystore

証明書のフィンガープリントとして、SHA-1の署名が出力されます。
署名の例(公式ドキュメントのもの):D7:02:2A:5D:2A:81:8F:BC:3E:87:5D:59:89:FB:27:AB:08:32:2A:B6

署名から:を消し、それをバイナリにしてbase64エンコードします。

echo "D7:02:2A:5D:2A:81:8F:BC:3E:87:5D:59:89:FB:27:AB:08:32:2A:B6" | tr -d ':'| xxd -r -p | base64

すると、以下の文字列が得られます。
1wIqXSqBj7w+h11ZifsnqwgyKrY=

これをURLエンコードします。

python3 -c 'import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))' "1wIqXSqBj7w+h11ZifsnqwgyKrY="

1wIqXSqBj7w%2Bh11ZifsnqwgyKrY%3D
これをredirect_uriに設定します。

Google Play

Google Play署名は、Google PlayConsoleの テストとリリース > アプリの完全性 > アプリの署名 から確認できます。
ここでSHA-1証明書のフィンガープリントをコピーして、ローカルと同じ手順でbase64文字列を生成できます。
または、Google PlayConsoleで「証明書をダウンロード」を押してDLできる証明書ファイルdeployment_cert.derを使うこともできます。
その場合、以下のコマンドを使うことで、base64文字列を生成できます。

openssl sha1 -binary deployment_cert.der | base64

authorization_user_agent

DEFAULT,WEBVIEW,BROWSERの3種類のうちから選択します。(詳しい説明)
ブローカー認証(Microsoft Authenticator / Intune Company Portal など)を使わない場合、BROWSERを選択します。(こちらのドキュメント)に説明があります。 当アプリではBROWSERを選択しました。

broker_redirect_uri_registered

ブローカー認証を行う場合はtrueにします。当アプリでは、行わないためfalseです。

account_mode

SINGLEMULTIがあります。

パラメーター 説明
SINGLE 同時に扱えるアカウントが常に一つ。社内業務(LoB)アプリ/共有デバイス(キオスク・現場端末)などに向く。
MULTI 複数アカウントが存在でき、切り替えられる。個人/組織アカウントを切り替えるユースケースに向く。

今回はMULTIを選択しました。
1つに制限する必要がなく、すでにブラウザでMicrosoftアカウントにログイン済みの場合でも、別のアカウントと紐づけることを可能とするためです。

AndroidManifest

次にAndroidManifestの例です。
authorization_user_agentBROWSERを選択した場合は、AndroidManifestに定義が必要です。

<activity
    android:name="com.microsoft.identity.client.BrowserTabActivity"
    android:exported="true">
    <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="msauth"
            android:host="com.azuresamples.msalandroidapp"
            android:path="/1wIqXSqBj7w+h11ZifsnqwgyKrY=" />
    </intent-filter>
</activity>

BrowserTabActivityは、ブラウザからアプリを起動するためのもので、ライブラリ内に定義されています。
intent-filterのdataに、アプリ固有の設定をしていきます。

schemeはmsauth固定です。
hostには、アプリケーションIDを設定します。
applicationIdSuffixなどでバリアントによってapplicationIdを変えている場合は、バリアントごとのstrings.xmlにapplicationIdを定義しておくとAndroidManifestの定義が1つで良くなり、メンテナンス性が上がります。
pathには/とbase64エンコードされた署名を定義します。
jsonの定義と異なり、こちらはURLエンコードしないので注意が必要です。
こちらも、バリアントがあればそれごとにstrings.xmlに定義すると良いと思います。

コード例

ここまでで設定が完了したので、ライブラリを使うコード例を示します。(MultipleAccountの例です)
大枠の使い方としては、以下になります。

  1. IMultipleAccountPublicClientApplicationの作成
  2. 作成したApplicationのacquireTokenを呼ぶ
  3. acquireTokenの引数にcallbackを指定でき、そこに結果が返ってくる

コード例では、callbackをsuspend関数に変換し、MicrosoftIdToken(value class)を返すクラスを作成しています。

internal class MicrosoftAuthenticationImpl @Inject constructor(
    @ApplicationContext private val context: Context,
    @Dispatcher(DispatcherType.IO) private val ioDispatcher: CoroutineDispatcher
): MicrosoftAuthentication {

    override suspend fun requestMicrosoftIdToken(activity: Activity, nonce: Nonce): Result<MicrosoftIdToken> = withContext(ioDispatcher) {
        when (val result = createMultipleAccountPublicClientApplication()) {
            is Result.Success -> {
                suspendCancellableCoroutine<Result<MicrosoftIdToken>> {
                    val parameters: AcquireTokenParameters = AcquireTokenParameters.Builder()
                        .startAuthorizationFromActivity(activity)
                        .withScopes(listOf("email"))
                        .withPrompt(Prompt.SELECT_ACCOUNT)
                        .withAuthorizationQueryStringParameters(listOf(AbstractMap.SimpleEntry("nonce", nonce.value)))
                        .withCallback(createAuthenticationCallback(it))
                        .build()
                    val clientApplication = result.value
                    clientApplication.acquireToken(parameters)
                }
            }
            is Result.Error -> {
                Result.Error(result.exception)
            }
        }
    }

    private suspend fun createMultipleAccountPublicClientApplication(): Result<IMultipleAccountPublicClientApplication> {
        return suspendCancellableCoroutine {
            PublicClientApplication.createMultipleAccountPublicClientApplication(
                context,
                R.raw.credential_microsoft_auth_config,
                object : IMultipleAccountApplicationCreatedListener {
                    override fun onCreated(application: IMultipleAccountPublicClientApplication) {
                        it.resume(Result.Success(application))
                    }

                    override fun onError(exception: MsalException) {
                        it.resume(Result.Error(exception))
                    }
                })
        }
    }

    private fun createAuthenticationCallback(continuation: Continuation<Result<MicrosoftIdToken>>): AuthenticationCallback {
        return object: AuthenticationCallback {
            override fun onSuccess(authenticationResult: IAuthenticationResult?) {
                val idToken = authenticationResult?.account?.idToken
                if (idToken != null) {
                    continuation.resume(Result.Success(MicrosoftIdToken(idToken)))
                } else {
                    val exception = IllegalStateException("ID Token is null.")
                    continuation.resume(Result.Error(exception))
                }
            }

            override fun onCancel() {
                continuation.resume(Result.Error(SsoAuthorizationCancellationException()))
            }

            override fun onError(exception: MsalException) {
                if (exception.errorCode == MsalServiceException.ACCESS_DENIED) {
                    continuation.resume(Result.Error(SsoAuthorizationCancellationException()))
                } else {
                    continuation.resume(Result.Error(exception))
                }
            }
        }
    }
}

createMultipleAccountPublicClientApplication

createMultipleAccountPublicClientApplication では、IMultipleAccountPublicClientApplicationを作成します。
R.raw.credential_microsoft_auth_config は、先に説明したjson形式の設定ファイルです。

createMultipleAccountPublicClientApplicationに成功すると、IMultipleAccountPublicClientApplicationが取得でき、ID Token取得のための acquireTokenを呼ぶことができます。

AcquireTokenParameters

acquireTokenに必要な AcquireTokenParametersの引数はScopes, Activity, Callbackが必須となっています。(ドキュメント)

withScopesでは、emailを指定しており、これは受け入れ可能なドメインを学校で設定し、それを検証するためです。
scopeについてはResources and scopesが参考になります。

withPromptは、SELECT_ACCOUNTを指定しており、認証済みのアカウントがなければログイン画面、あればアカウントを選択する画面を表示するようにしています。
withPromptの選択肢と説明はPrompt Enumが参考になります。

withAuthorizationQueryStringParametersには、nonceを入れています。 先に説明したnonceですが、Apple/Google/Microsoftすべての認証で利用しています。
ID Tokenの中に入っているnonceを検証するために付与しています。

withCallbackで指定したCallbackに結果が返るようになっています。
これをsuspend関数にするため、suspendCancellableCoroutineを使っています。

createAuthenticationCallback

認証に成功した場合、authenticationResult > account > idTokenにID Tokenが入っています。

動作確認の際の注意点

MicrosoftSSOも、GoogleSSOと同様に署名情報を利用するため、動作確認の際にはGoogle Playからの配信など適切な方法を選ぶ必要があります。

おわりに

今回は、Apple/Google/MicrosoftのSSOについて解説しました。
ライブラリや環境設定など、注意するポイントがあり、調べながら試しながら実装していきました。
認証周りの導入はそう何度もあることではないので、良い経験ができたと思っています。