Android で Firebase 電話番号確認フローをカスタマイズする

Firebase Phone Number Verification を始めるページでは、getVerifiedPhoneNumber() メソッドを使用して Firebase PNV と統合する方法について詳しく説明しています。このメソッドは、ユーザーの同意の取得から Firebase PNV バックエンドへの必要なネットワーク呼び出しの実行まで、Firebase PNV フロー全体を処理します。

ほとんどのデベロッパーには、単一メソッド API(getVerifiedPhoneNumber())が推奨されます。ただし、Android 認証情報マネージャーとのやり取りをより細かく制御する必要がある場合(電話番号とともに他の認証情報をリクエストするなど)、Firebase PNV ライブラリには次の 2 つのメソッドも用意されています。これらのメソッドはそれぞれ、Firebase PNV バックエンドとの異なるやり取りを処理します。

  • getDigitalCredentialPayload() は、認証情報マネージャーの呼び出しに使用するサーバー署名付きリクエストを取得します。
  • exchangeCredentialResponseForPhoneNumber() は、Credential Manager からのレスポンスを、確認済みの電話番号を含む署名付きトークンと交換します。

これらの各メソッドの呼び出しの間で、Android の Credential Manager API とのやり取りを処理する必要があります。このページでは、この 3 部構成のフローを実装する方法の概要について説明します。

始める前に

スタートガイド ページの説明に沿って、Firebase プロジェクトを設定し、Firebase PNV 依存関係をインポートします。

1. デジタル認証情報リクエストのペイロードを取得する

getDigitalCredentialPayload() メソッドを呼び出して、デバイスの電話番号のリクエストを生成します。次のステップでは、このリクエストが Credential Manager API とのやり取りのペイロードになります。

// This instance does not require an Activity context.
val fpnv = FirebasePhoneNumberVerification.getInstance()

// Your request should include a nonce, which will propagate through the flow
// and be present in the final response from FPNV. See the section "Verifying
// the Firebase PNV token" for details on generating and verifying this.
val nonce = fetchNonceFromYourServer()

fpnv.getDigitalCredentialPayload(nonce, "https://example.com/privacy-policy")
  .addOnSuccessListener { fpnvDigitalCredentialPayload ->
    // Use the payload in the next step.
    // ...
  }
  .addOnFailureListener { e -> /* Handle payload fetch failure */ }

2. 認証情報マネージャーを使用してデジタル認証情報リクエストを行う

次に、リクエストを認証情報マネージャーに渡します。

そのためには、リクエスト ペイロードを DigitalCredential API リクエストでラップする必要があります。このリクエストには、getDigitalCredentialPayload() に渡したのと同じ nonce を含める必要があります。

// This example uses string interpolation for clarity, but you should use some kind of type-safe
// serialization method.
fun buildDigitalCredentialRequestJson(nonce: String, fpnvDigitalCredentialPayload: String) = """
    {
      "requests": [
        {
          "protocol": "openid4vp-v1-unsigned",
          "data": {
            "response_type": "vp_token",
            "response_mode": "dc_api",
            "nonce": "$nonce",
            "dcql_query": { "credentials": [$fpnvDigitalCredentialPayload] }
          }
        }
      ]
    }
""".trimIndent()

この処理が完了したら、Credential Manager API を使用してリクエストを行うことができます。

suspend fun makeFpnvRequest(
  context: Activity, nonce: String, fpnvDigitalCredentialPayload: String): GetCredentialResponse {
  // Helper function to build the digital credential request (defined above).
  // Pass the same nonce you passed to getDigitalCredentialPayload().
  val digitalCredentialRequestJson =
    buildDigitalCredentialRequestJson(nonce, fpnvDigitalCredentialPayload)

  // CredentialManager requires an Activity context.
  val credentialManager = CredentialManager.create(context)

  // Build a Credential Manager request that includes the Firebase PNV option. Note that
  // you can't combine the digital credential option with other options.
  val request = GetCredentialRequest.Builder()
    .addCredentialOption(GetDigitalCredentialOption(digitalCredentialRequestJson))
    .build()

  // getCredential is a suspend function, so it must run in a coroutine scope,
  val cmResponse: GetCredentialResponse = try {
    credentialManager.getCredential(context, request)
  } catch (e: GetCredentialException) {
    // If the user cancels the operation, the feature isn't available, or the
    // SIM doesn't support the feature, a GetCredentialCancellationException
    // will be returned. Otherwise, a GetCredentialUnsupportedException will
    // be returned with details in the exception message.
    throw e
  }
  return cmResponse
}

Credential Manager の呼び出しが成功すると、レスポンスにデジタル認証情報が含まれます。次の例のようなコードを使用して、この認証情報を抽出できます。

val dcApiResponse = extractApiResponse(cmResponse)
fun extractApiResponse(response: GetCredentialResponse): String {
  val credential = response.credential
  when (credential) {
    is DigitalCredential -> {
      val json = JSONObject(credential.credentialJson)
      val firebaseJwtArray =
          json.getJSONObject("data").getJSONObject("vp_token").getJSONArray("firebase")
      return firebaseJwtArray.getString(0)

    }
    else -> {
      // Handle any unrecognized credential type here.
      Log.e(TAG, "Unexpected type of credential ${credential.type}")
    }
  }
}

3. デジタル認証情報のレスポンスを Firebase PNV トークンと交換する

最後に、exchangeCredentialResponseForPhoneNumber() メソッドを呼び出して、デジタル認証情報のレスポンスを検証済みの電話番号と Firebase PNV トークンに交換します。

fpnv.exchangeCredentialResponseForPhoneNumber(dcApiResponse)
  .addOnSuccessListener { result ->
    val phoneNumber = result.getPhoneNumber()
    // Verification successful
  }
  .addOnFailureListener { e -> /* Handle exchange failure */ }

4. Firebase PNV トークンの検証

フローが成功すると、getVerifiedPhoneNumber() メソッドは確認済みの電話番号と、その電話番号を含む署名付きトークンを返します。このデータは、プライバシー ポリシーに記載されているとおりにアプリで使用できます。

アプリ クライアントの外部で確認済みの電話番号を使用する場合は、電話番号自体ではなくトークンを渡して、使用時に完全性を確認できるようにする必要があります。トークンを検証するには、次の 2 つのエンドポイントを実装する必要があります。

  • ノンス生成エンドポイント
  • トークン検証エンドポイント

これらのエンドポイントの実装はユーザーが行います。次の例では、Node.js と Express を使用して実装する方法を示します。

ノンスの生成

このエンドポイントは、ノンスと呼ばれる 1 回限りの値を生成して一時的に保存します。この値は、エンドポイントに対するリプレイ攻撃を防ぐために使用されます。たとえば、次のように定義された Express ルートがあるとします。

app.get('/fpnvNonce', async (req, res) => {
    const nonce = crypto.randomUUID();

    // TODO: Save the nonce to a database, key store, etc.
    // You should also assign the nonce an expiration time and periodically
    // clear expired nonces from your database.
    await persistNonce({
        nonce,
        expiresAt: Date.now() + 180000, // Give it a short duration.
    });

    // Return the nonce to the caller.
    res.send({ nonce });
});

これは、ステップ 1 のプレースホルダ関数 fetchNonceFromYourServer() が呼び出すエンドポイントです。ノンスは、クライアントが実行するさまざまなネットワーク呼び出しを介して伝播し、最終的に Firebase PNV トークンでサーバーに戻ります。次のステップでは、生成したノンスがトークンに含まれていることを確認します。

トークンの検証

このエンドポイントは、クライアントから Firebase PNV トークンを受け取り、その信頼性を検証します。トークンを確認するには、次のことを確認する必要があります。

  • トークンは、Firebase PNV JWKS エンドポイントで公開されている鍵のいずれかを使用して署名されます。

    https://fpnv.googleapis.com/v1beta/jwks
    
  • オーディエンスと発行者のクレームには Firebase プロジェクト番号が含まれており、次の形式になっています。

    https://fpnv.googleapis.com/projects/FIREBASE_PROJECT_NUMBER
    

    Firebase プロジェクト番号は、Firebase コンソールの [プロジェクトの設定] ページで確認できます。

  • トークンの有効期限が切れていない。

  • トークンに有効なノンスが含まれています。ノンスは、次の条件を満たす場合に有効です。

    • 生成した(つまり、使用している永続化メカニズムで見つかる)
    • まだ使用されていないこと
    • 有効期限が切れていない

たとえば、Express の実装は次のようになります。

import { JwtVerifier } from "aws-jwt-verify";

// Find your Firebase project number in the Firebase console.
const FIREBASE_PROJECT_NUMBER = "123456789";

// The issuer and audience claims of the FPNV token are specific to your
// project.
const issuer = `https://fpnv.googleapis.com/projects/${FIREBASE_PROJECT_NUMBER}`;
const audience = `https://fpnv.googleapis.com/projects/${FIREBASE_PROJECT_NUMBER}`;

// The JWKS URL contains the current public signing keys for FPNV tokens.
const jwksUri = "https://fpnv.googleapis.com/v1beta/jwks";

// Configure a JWT verifier to check the following:
// - The token is signed by Google
// - The issuer and audience claims match your project
// - The token has not yet expired (default begavior)
const fpnvVerifier = JwtVerifier.create({ issuer, audience, jwksUri });

app.post('/verifiedPhoneNumber', async (req, res) => {
    if (!req.body) return res.sendStatus(400);
    // Get the token from the body of the request.
    const fpnvToken = req.body;
    try {
        // Attempt to verify the token using the verifier configured above.
        const verifiedPayload = await fpnvVerifier.verify(fpnvToken);

        // Now that you've verified the signature and claims, verify the nonce.
        // TODO: Try to look up the nonce in your database and remove it if it's
        // found; if it's not found or it's expired, throw an error.
        await testAndRemoveNonce(verifiedPayload.nonce);

        // Only after verifying the JWT signature, claims, and nonce, get the
        // verified phone number from the subject claim.
        // You can use this value however it's needed by your app.
        const verifiedPhoneNumber = verifiedPayload.sub;
        // (Do something with it...)

        return res.sendStatus(200);
    } catch {
        // If verification fails, reject the token.
        return res.sendStatus(400);
    }
});