WebView でユーザーを認証する

このドキュメントでは、WebView を使用する Android アプリに Credential Manager API を統合する方法について説明します。

概要

統合プロセスの説明に入る前に、ネイティブ Android コード、WebView 内にレンダリングされてアプリの認証を管理するウェブ コンポーネント、バックエンドの間の通信フローを理解することが重要です。このフローには、登録(認証情報の作成)と認証(既存の認証情報の取得)が含まれます。

登録(パスキーの作成)

  1. バックエンドは初期の登録 JSON を生成し、WebView 内にレンダリングされたウェブページに送信します。
  2. ウェブページは navigator.credentials.create() を使用して新しい認証情報を登録します。挿入された JavaScript を使用して、後のステップでこのメソッドをオーバーライドし、リクエストを Android アプリに送信します。
  3. Android アプリは、Credential Manager API を使用して認証情報のリクエストを作成し、createCredential に使用します。
  4. Credential Manager API が公開鍵認証情報をアプリと共有します。
  5. アプリは公開鍵認証情報をウェブページに送り返し、挿入された JavaScript がレスポンスを解析できるようにします。
  6. ウェブページが公開鍵をバックエンドに送信します。バックエンドは公開鍵を検証して保存します。
パスキー登録フローを示すチャート
図 1. パスキー登録フロー。

認証(パスキーの取得)

  1. バックエンドは、認証 JSON を生成して認証情報を取得し、WebView クライアントにレンダリングされたウェブページに送信します。
  2. ウェブページは navigator.credentials.get を使用します。挿入された JavaScript を使用してこのメソッドをオーバーライドし、リクエストを Android アプリにリダイレクトします。
  3. アプリは、getCredential を呼び出して Credential Manager API を使用し、認証情報を取得します。
  4. Credential Manager API がアプリに認証情報を返します。
  5. アプリは秘密鍵のデジタル署名を取得してウェブページに送信し、挿入された JavaScript がレスポンスを解析できるようにします。
  6. その後、ウェブページがそれをサーバーに送信し、サーバーは公開鍵を使用してデジタル署名を検証します。
パスキー認証フローを示すチャート
図 2. パスキー認証フロー。

パスワードやフェデレーション ID システムにも同じフローを使用できます。

前提条件

Credential Manager API を使用するには、認証情報マネージャー ガイドの前提条件セクションに記載されている手順を実施し、次の操作を行ってください。

JavaScript の通信

WebView の JavaScript とネイティブ Android コードが相互に通信できるようにするには、2 つの環境間でメッセージを送信し、リクエストを処理する必要があります。そのためには、カスタムの JavaScript コードを WebView に挿入します。すると、ウェブ コンテンツの動作を変更したり、ネイティブ Android コードとやり取りしたりできるようになります。

JavaScript の挿入

以下の JavaScript コードは、WebView と Android アプリの間の通信を確立します。これは、先述した登録と認証のフローで WebAuthn API が使用する navigator.credentials.create() メソッドと navigator.credentials.get() メソッドをオーバーライドします。

アプリではこの JavaScript コードの圧縮版を使用します。

パスキーのリスナーを作成する

JavaScript との通信を処理する PasskeyWebListener クラスをセットアップします。このクラスは WebViewCompat.WebMessageListener から継承する必要があります。このクラスは、JavaScript からメッセージを受け取り、Android アプリで必要なアクションを実行します。

Kotlin

// The class talking to Javascript should inherit:
class PasskeyWebListener(
    private val activity: Activity,
    private val coroutineScope: CoroutineScope,
    private val credentialManagerHandler: CredentialManagerHandler
) : WebViewCompat.WebMessageListener

// ... Implementation details

Java

// The class talking to Javascript should inherit:
class PasskeyWebListener implements WebViewCompat.WebMessageListener {

  // Implementation details
  private Activity activity;

  // Handles get/create methods meant for Java:
  private CredentialManagerHandler credentialManagerHandler;

  public PasskeyWebListener(
    Activity activity,
    CredentialManagerHandler credentialManagerHandler
    ) {
    this.activity = activity;
    this.credentialManagerHandler = credentialManagerHandler;
  }

// ... Implementation details
}

以降のセクションで説明するように、PasskeyWebListener 内にリクエストとレスポンスのロジックを実装します。

認証リクエストを処理する

JavaScript コードが Android アプリにメッセージを送信すると、WebAuthn の navigator.credentials.create() オペレーションまたは navigator.credentials.get() オペレーションのリクエストを処理するために、PasskeyWebListener クラスの onPostMessage メソッドが呼び出されます。

Kotlin

class PasskeyWebListener(...)... {
// ...

  /** havePendingRequest is true if there is an outstanding WebAuthn request.
      There is only ever one request outstanding at a time. */
  private var havePendingRequest = false

  /** pendingRequestIsDoomed is true if the WebView has navigated since
      starting a request. The FIDO module cannot be canceled, but the response
      will never be delivered in this case. */
  private var pendingRequestIsDoomed = false

  /** replyChannel is the port that the page is listening for a response on.
      It is valid if havePendingRequest is true. */
  private var replyChannel: ReplyChannel? = null

  /**
  * Called by the page during a WebAuthn request.
  *
  * @param view Creates the WebView.
  * @param message The message sent from the client using injected JavaScript.
  * @param sourceOrigin The origin of the HTTPS request. Should not be null.
  * @param isMainFrame Should be set to true. Embedded frames are not
    supported.
  * @param replyProxy Passed in by JavaScript. Allows replying when wrapped in
    the Channel.
  * @return The message response.
  */
  @UiThread
  override fun onPostMessage(
    view: WebView,
    message: WebMessageCompat,
    sourceOrigin: Uri,
    isMainFrame: Boolean,
    replyProxy: JavaScriptReplyProxy,
  ) {
    val messageData = message.data ?: return
    onRequest(
      messageData,
      sourceOrigin,
      isMainFrame,
      JavaScriptReplyChannel(replyProxy)
    )
  }

  private fun onRequest(
    msg: String,
    sourceOrigin: Uri,
    isMainFrame: Boolean,
    reply: ReplyChannel,
  ) {
    msg?.let {
      val jsonObj = JSONObject(msg);
      val type = jsonObj.getString(TYPE_KEY)
      val message = jsonObj.getString(REQUEST_KEY)

      if (havePendingRequest) {
        postErrorMessage(reply, "The request already in progress", type)
        return
      }

      replyChannel = reply
      if (!isMainFrame) {
        reportFailure("Requests from subframes are not supported", type)
        return
      }
      val originScheme = sourceOrigin.scheme
      if (originScheme == null || originScheme.lowercase() != "https") {
        reportFailure("WebAuthn not permitted for current URL", type)
        return
      }

      // Verify that origin belongs to your website,
      // it's because the unknown origin may gain credential info.
      if (isUnknownOrigin(originScheme)) {
        return
      }

      havePendingRequest = true
      pendingRequestIsDoomed = false

      // Use a temporary "replyCurrent" variable to send the data back, while
      // resetting the main "replyChannel" variable to null so it’s ready for
      // the next request.
      val replyCurrent = replyChannel
      if (replyCurrent == null) {
        Log.i(TAG, "The reply channel was null, cannot continue")
        return;
      }

      when (type) {
        CREATE_UNIQUE_KEY ->
          this.coroutineScope.launch {
            handleCreateFlow(credentialManagerHandler, message, replyCurrent)
          }

        GET_UNIQUE_KEY -> this.coroutineScope.launch {
          handleGetFlow(credentialManagerHandler, message, replyCurrent)
        }

        else -> Log.i(TAG, "Incorrect request json")
      }
    }
  }

  private suspend fun handleCreateFlow(
    credentialManagerHandler: CredentialManagerHandler,
    message: String,
    reply: ReplyChannel,
  ) {
    try {
      havePendingRequest = false
      pendingRequestIsDoomed = false
      val response = credentialManagerHandler.createPasskey(message)
      val successArray = ArrayList<Any>();
      successArray.add("success");
      successArray.add(JSONObject(response.registrationResponseJson));
      successArray.add(CREATE_UNIQUE_KEY);
      reply.send(JSONArray(successArray).toString())
      replyChannel = null // setting initial replyChannel for the next request
    } catch (e: CreateCredentialException) {
      reportFailure(
        "Error: ${e.errorMessage} w type: ${e.type} w obj: $e",
        CREATE_UNIQUE_KEY
      )
    } catch (t: Throwable) {
      reportFailure("Error: ${t.message}", CREATE_UNIQUE_KEY)
    }
  }

  companion object {
    const val TYPE_KEY = "type"
    const val REQUEST_KEY = "request"
    const val CREATE_UNIQUE_KEY = "create"
    const val GET_UNIQUE_KEY = "get"
  }
}

Java

class PasskeyWebListener implements ... {
// ...

  /**
  * Called by the page during a WebAuthn request.
  *
  * @param view Creates the WebView.
  * @param message The message sent from the client using injected JavaScript.
  * @param sourceOrigin The origin of the HTTPS request. Should not be null.
  * @param isMainFrame Should be set to true. Embedded frames are not
    supported.
  * @param replyProxy Passed in by JavaScript. Allows replying when wrapped in
    the Channel.
  * @return The message response.
  */
  @UiThread
  public void onPostMessage(
    @NonNull WebView view,
    @NonNull WebMessageCompat message,
    @NonNull Uri sourceOrigin,
    Boolean isMainFrame,
    @NonNull JavaScriptReplyProxy replyProxy,
  ) {
      if (messageData == null) {
        return;
    }
    onRequest(
      messageData,
      sourceOrigin,
      isMainFrame,
      JavaScriptReplyChannel(replyProxy)
    )
  }

  private void onRequest(
    String msg,
    Uri sourceOrigin,
    boolean isMainFrame,
    ReplyChannel reply
  ) {
      if (msg != null) {
        try {
          JSONObject jsonObj = new JSONObject(msg);
          String type = jsonObj.getString(TYPE_KEY);
          String message = jsonObj.getString(REQUEST_KEY);

          boolean isCreate = type.equals(CREATE_UNIQUE_KEY);
          boolean isGet = type.equals(GET_UNIQUE_KEY);

          if (havePendingRequest) {
              postErrorMessage(reply, "The request already in progress", type);
              return;
          }
          replyChannel = reply;
          if (!isMainFrame) {
              reportFailure("Requests from subframes are not supported", type);
              return;
          }
          String originScheme = sourceOrigin.getScheme();
          if (originScheme == null || !originScheme.toLowerCase().equals("https")) {
              reportFailure("WebAuthn not permitted for current URL", type);
              return;
          }

          // Verify that origin belongs to your website,
          // Requests of unknown origin may gain access to credential info.
          if (isUnknownOrigin(originScheme)) {
            return;
          }

          havePendingRequest = true;
          pendingRequestIsDoomed = false;

          // Use a temporary "replyCurrent" variable to send the data back,
          // while resetting the main "replyChannel" variable to null so it’s
          // ready for the next request.

          ReplyChannel replyCurrent = replyChannel;
          if (replyCurrent == null) {
              Log.i(TAG, "The reply channel was null, cannot continue");
              return;
          }

          if (isCreate) {
              handleCreateFlow(credentialManagerHandler, message, replyCurrent));
          } else if (isGet) {
              handleGetFlow(credentialManagerHandler, message, replyCurrent));
          } else {
              Log.i(TAG, "Incorrect request json");
          }
        } catch (JSONException e) {
        e.printStackTrace();
      }
    }
  }
}

handleCreateFlowhandleGetFlow については、GitHub の例をご覧ください。

レスポンスを処理する

ネイティブ アプリからウェブページに送信されるレスポンスを処理するには、JavaScriptReplyChannel 内に JavaScriptReplyProxy を追加します。

Kotlin

class PasskeyWebListener(...)... {
// ...
  // The setup for the reply channel allows communication with JavaScript.
  private class JavaScriptReplyChannel(private val reply: JavaScriptReplyProxy) :
    ReplyChannel {
    override fun send(message: String?) {
      try {
        reply.postMessage(message!!)
      } catch (t: Throwable) {
        Log.i(TAG, "Reply failure due to: " + t.message);
      }
    }
  }

  // ReplyChannel is the interface where replies to the embedded site are
  // sent. This allows for testing since AndroidX bans mocking its objects.
  interface ReplyChannel {
    fun send(message: String?)
  }
}

Java

class PasskeyWebListener implements ... {
// ...

  // The setup for the reply channel allows communication with JavaScript.
  private static class JavaScriptReplyChannel implements ReplyChannel {
    private final JavaScriptReplyProxy reply;

    JavaScriptReplyChannel(JavaScriptReplyProxy reply) {
      this.reply = reply;
    }

    @Override
    public void send(String message) {
      reply.postMessage(message);
    }
  }

  // ReplyChannel is the interface where replies to the embedded site are
  // sent. This allows for testing since AndroidX bans mocking its objects.
  interface ReplyChannel {
    void send(String message);
  }
}

ネイティブ アプリのエラーは必ず捕捉して JavaScript 側に送り返してください。

WebView と統合する

このセクションでは、WebView の統合をセットアップする方法について説明します。

WebView を初期化する

Android アプリのアクティビティで、WebView を初期化し、付随する WebViewClient をセットアップします。WebViewClient は、WebView に挿入された JavaScript コードとの通信を処理します。

WebView をセットアップし、認証情報マネージャーを呼び出します。

Kotlin

val credentialManagerHandler = CredentialManagerHandler(this)
// ...

AndroidView(factory = {
  WebView(it).apply {
    settings.javaScriptEnabled = true

    // Test URL:
    val url = "https://credman-web-test.glitch.me/"
    val listenerSupported = WebViewFeature.isFeatureSupported(
      WebViewFeature.WEB_MESSAGE_LISTENER
    )
    if (listenerSupported) {
      // Inject local JavaScript that calls Credential Manager.
      hookWebAuthnWithListener(this, this@MainActivity,
      coroutineScope, credentialManagerHandler)
      } else {
        // Fallback routine for unsupported API levels.
      }
      loadUrl(url)
    }
  }
)

Java

// Example shown in the onCreate method of an Activity

@Override
protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  setContentView(R.layout.activity_main);

  WebView webView = findViewById(R.id.web_view);
  // Test URL:
  String url = "https://credman-web-test.glitch.me/";
  Boolean listenerSupported = WebViewFeature.isFeatureSupported(
    WebViewFeature.WEB_MESSAGE_LISTENER
  );
  if (listenerSupported) {
    // Inject local JavaScript that calls Credential Manager.
    hookWebAuthnWithListener(webView, this,
      coroutineScope, credentialManagerHandler)
  } else {
    // Fallback routine for unsupported API levels.
  }
  webView.loadUrl(url);
}

新しい WebView クライアント オブジェクトを作成し、ウェブページに JavaScript を挿入します。

Kotlin

// This is an example call into hookWebAuthnWithListener
val passkeyWebListener = PasskeyWebListener(
  activity, coroutineScope, credentialManagerHandler
)

val webViewClient = object : WebViewClient() {
  override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
    super.onPageStarted(view, url, favicon)
    // Handle page load events
    passkeyWebListener.onPageStarted();
    webView.evaluateJavascript(PasskeyWebListener.INJECTED_VAL, null)
  }
}

webView.webViewClient = webViewClient

Java

// This is an example call into hookWebAuthnWithListener
PasskeyWebListener passkeyWebListener = new PasskeyWebListener(
  activity, credentialManagerHandler
)

WebViewClient webiewClient = new WebViewClient() {
  @Override
  public void onPageStarted(WebView view, String url, Bitmap favicon) {
    super.onPageStarted(view, url, favicon);
    // Handle page load events
    passkeyWebListener.onPageStarted();
    webView.evaulateJavascript(PasskeyWebListener.INJECTED_VAL, null);
  }
};

webView.setWebViewClient(webViewClient);

ウェブ メッセージ リスナーをセットアップする

JavaScript と Android アプリの間でメッセージを送信できるようにするには、WebViewCompat.addWebMessageListener メソッドでウェブ メッセージ リスナーをセットアップします。

Kotlin

val rules = setOf("*")
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
  WebViewCompat.addWebMessageListener(
    webView, PasskeyWebListener.INTERFACE_NAME, rules, passkeyWebListener
  )
}

Java

Set<String> rules = new HashSet<>(Arrays.asList("*"));

if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
  WebViewCompat.addWebMessageListener(
    webView, PasskeyWebListener.INTERFACE_NAME, rules, passkeyWebListener
  )
}

ウェブ インテグレーション

ウェブ インテグレーションの構築方法については、パスワードレス ログイン用のパスキーを作成するフォームの自動入力でパスキーを使用してログインするをご覧ください。

テストとデプロイ

制御された環境でフロー全体を徹底的にテストし、Android アプリ、ウェブページ、バックエンドの間で適切な通信が行われることを確認します。

統合されたソリューションを本番環境にデプロイし、受け取った登録リクエストと認証リクエストをバックエンドで処理できるようにします。バックエンド コードは、登録(create)プロセスと認証(get)プロセス用に初期の JSON を生成する必要があります。また、ウェブページから受け取ったレスポンスの妥当性確認と検証を処理する必要もあります。

実装が UX の推奨事項に対応していることを確認します。

重要な注意点

  • 提供されている JavaScript コードを使用して、navigator.credentials.create() オペレーションと navigator.credentials.get() オペレーションを処理します。
  • PasskeyWebListener クラスは、WebView の JavaScript コードと Android アプリの間のブリッジです。メッセージの受け渡し、通信、必要なアクションの実行を処理します。
  • 提供されているコード スニペットは、プロジェクトの構造、命名規則、特定の要件に合わせて変更してください。
  • ネイティブ アプリ側でエラーを捕捉して JavaScript 側に送り返します。

このガイドに沿って、WebView を使用する Android アプリに Credential Manager API を統合することで、認証情報を効果的に管理しながら、パスキーに対応した安全でシームレスなログイン エクスペリエンスをユーザーに提供できます。