В этом документе описывается, как интегрировать API Credential Manager с приложением Android, использующим WebView.
Обзор
Прежде чем углубляться в процесс интеграции, важно понять схему взаимодействия между нативным кодом Android, веб-компонентом, отображаемым в WebView и управляющим аутентификацией вашего приложения, и бэкендом. Схема взаимодействия включает в себя регистрацию (создание учётных данных) и аутентификацию (получение существующих учётных данных).
Регистрация (создание ключа доступа)
- Бэкэнд генерирует первоначальный регистрационный JSON и отправляет его на веб-страницу, отображаемую в WebView.
-  Веб-страница использует navigator.credentials.create()для регистрации новых учётных данных. Вы будете использовать внедрённый JavaScript для переопределения этого метода на следующем этапе, чтобы отправить запрос в приложение Android.
-  Приложение Android использует API Credential Manager для создания запроса на учетные данные и использует его для createCredential.
- API диспетчера учетных данных передает учетные данные открытого ключа приложению.
- Приложение отправляет учетные данные открытого ключа обратно на веб-страницу, чтобы внедренный JavaScript мог проанализировать ответы.
- Веб-страница отправляет открытый ключ на серверную часть, которая проверяет и сохраняет открытый ключ.

Аутентификация (получение ключа доступа)
- Бэкэнд генерирует JSON-данные аутентификации для получения учетных данных и отправляет их на веб-страницу, которая отображается в клиенте WebView.
-  Веб-страница использует navigator.credentials.get. Используйте внедренный JavaScript для переопределения этого метода, чтобы перенаправить запрос в приложение Android.
-  Приложение извлекает учетные данные с помощью API диспетчера учетных данных, вызывая getCredential.
- API диспетчера учетных данных возвращает учетные данные в приложение.
- Приложение получает цифровую подпись закрытого ключа и отправляет ее на веб-страницу, чтобы внедренный JavaScript мог проанализировать ответы.
- Затем веб-страница отправляет его на сервер, который проверяет цифровую подпись с помощью открытого ключа.

Тот же процесс можно использовать для паролей или систем федеративной идентификации.
Предпосылки
Чтобы использовать API диспетчера учетных данных, выполните действия, описанные в разделе предварительных требований руководства по диспетчеру учетных данных, и убедитесь, что вы выполнили следующие действия:
- Добавьте необходимые зависимости .
- Сохранить классы в файле ProGuard .
- Добавить поддержку ссылок на цифровые активы .
JavaScript-коммуникация
Чтобы JavaScript в WebView и нативный код Android могли взаимодействовать друг с другом, необходимо обмениваться сообщениями и обрабатывать запросы между этими двумя средами. Для этого необходимо внедрить собственный код JavaScript в WebView. Это позволит изменять поведение веб-контента и взаимодействовать с нативным кодом Android.
JavaScript-инъекция
 Следующий код JavaScript устанавливает связь между WebView и приложением Android. Он переопределяет методы navigator.credentials.create() и navigator.credentials.get() , используемые API WebAuthn для описанных ранее процессов регистрации и аутентификации.
Используйте уменьшенную версию этого кода JavaScript в своем приложении.
Создайте прослушиватель для паролей
 Создайте класс PasskeyWebListener , который обрабатывает взаимодействие с JavaScript. Этот класс должен наследовать от WebViewCompat.WebMessageListener . Этот класс получает сообщения от JavaScript и выполняет необходимые действия в приложении для Android.
 В следующих разделах описывается структура класса PasskeyWebListener , а также обработка запросов и ответов.
Обработать запрос аутентификации
 Для обработки запросов на операции WebAuthn navigator.credentials.create() или navigator.credentials.get() метод onPostMessage класса PasskeyWebListener вызывается, когда код JavaScript отправляет сообщение приложению Android:
// The class talking to Javascript should inherit:
class PasskeyWebListener(
    private val activity: Activity,
    private val coroutineScope: CoroutineScope,
    private val credentialManagerHandler: CredentialManagerHandler
) : WebViewCompat.WebMessageListener {
    /** 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 {
        /** INTERFACE_NAME is the name of the MessagePort that must be injected into pages. */
        const val INTERFACE_NAME = "__webauthn_interface__"
        const val TYPE_KEY = "type"
        const val REQUEST_KEY = "request"
        const val CREATE_UNIQUE_KEY = "create"
        const val GET_UNIQUE_KEY = "get"
        /** INJECTED_VAL is the minified version of the JavaScript code described at this class
         * heading. The non minified form is found at credmanweb/javascript/encode.js.*/
        const val INJECTED_VAL = """
            var __webauthn_interface__,__webauthn_hooks__;!function(e){console.log("In the hook."),__webauthn_interface__.addEventListener("message",function e(n){var r=JSON.parse(n.data),t=r[2];"get"===t?o(r):"create"===t?u(r):console.log("Incorrect response format for reply")});var n=null,r=null,t=null,a=null;function o(e){if(null!==n&&null!==t){if("success"!=e[0]){var r=t;n=null,t=null,r(new DOMException(e[1],"NotAllowedError"));return}var a=i(e[1]),o=n;n=null,t=null,o(a)}}function l(e){var n=e.length%4;return Uint8Array.from(atob(e.replace(/-/g,"+").replace(/_/g,"/").padEnd(e.length+(0===n?0:4-n),"=")),function(e){return e.charCodeAt(0)}).buffer}function s(e){return btoa(Array.from(new Uint8Array(e),function(e){return String.fromCharCode(e)}).join("")).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+${'$'}/,"")}function u(e){if(null===r||null===a){console.log("Here: "+r+" and reject: "+a);return}if(console.log("Output back: "+e),"success"!=e[0]){var n=a;r=null,a=null,n(new DOMException(e[1],"NotAllowedError"));return}var t=i(e[1]),o=r;r=null,a=null,o(t)}function i(e){return console.log("Here is the response from credential manager: "+e),e.rawId=l(e.rawId),e.response.clientDataJSON=l(e.response.clientDataJSON),e.response.hasOwnProperty("attestationObject")&&(e.response.attestationObject=l(e.response.attestationObject)),e.response.hasOwnProperty("authenticatorData")&&(e.response.authenticatorData=l(e.response.authenticatorData)),e.response.hasOwnProperty("signature")&&(e.response.signature=l(e.response.signature)),e.response.hasOwnProperty("userHandle")&&(e.response.userHandle=l(e.response.userHandle)),e.getClientExtensionResults=function e(){return{}},e}e.create=function n(t){if(!("publicKey"in t))return e.originalCreateFunction(t);var o=new Promise(function(e,n){r=e,a=n}),l=t.publicKey;if(l.hasOwnProperty("challenge")){var u=s(l.challenge);l.challenge=u}if(l.hasOwnProperty("user")&&l.user.hasOwnProperty("id")){var i=s(l.user.id);l.user.id=i}var c=JSON.stringify({type:"create",request:l});return __webauthn_interface__.postMessage(c),o},e.get=function r(a){if(!("publicKey"in a))return e.originalGetFunction(a);var o=new Promise(function(e,r){n=e,t=r}),l=a.publicKey;if(l.hasOwnProperty("challenge")){var u=s(l.challenge);l.challenge=u}var i=JSON.stringify({type:"get",request:l});return __webauthn_interface__.postMessage(i),o},e.onReplyGet=o,e.CM_base64url_decode=l,e.CM_base64url_encode=s,e.onReplyCreate=u}(__webauthn_hooks__||(__webauthn_hooks__={})),__webauthn_hooks__.originalGetFunction=navigator.credentials.get,__webauthn_hooks__.originalCreateFunction=navigator.credentials.create,navigator.credentials.get=__webauthn_hooks__.get,navigator.credentials.create=__webauthn_hooks__.create,window.PublicKeyCredential=function(){},window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable=function(){return Promise.resolve(!1)};
        """
    }
 Информацию о handleCreateFlow и handleGetFlow см. в примере на GitHub .
Обработайте ответ
 Для обработки ответов, отправляемых из собственного приложения на веб-страницу, добавьте JavaScriptReplyProxy в JavaScriptReplyChannel .
// 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?)
}
Обязательно перехватывайте все ошибки в собственном приложении и отправляйте их обратно в JavaScript.
Интеграция с WebView
В этом разделе описывается, как настроить интеграцию WebView.
Инициализируйте WebView
 В Activity вашего приложения Android инициализируйте WebView и настройте соответствующий WebViewClient . WebViewClient отвечает за взаимодействие с кодом JavaScript, внедрённым в WebView .
Настройте WebView и вызовите Credential Manager:
val credentialManagerHandler = CredentialManagerHandler(this)
setContent {
    val coroutineScope = rememberCoroutineScope()
    AndroidView(
        factory = {
            WebView(it).apply {
                settings.javaScriptEnabled = true
                // Test URL:
                val url = "https://passkeys-codelab.glitch.me/"
                val listenerSupported = WebViewFeature.isFeatureSupported(
                    WebViewFeature.WEB_MESSAGE_LISTENER
                )
                if (listenerSupported) {
                    // Inject local JavaScript that calls Credential Manager.
                    hookWebAuthnWithListener(
                        this, this@WebViewMainActivity,
                        coroutineScope, credentialManagerHandler
                    )
                } else {
                    // Fallback routine for unsupported API levels.
                }
                loadUrl(url)
            }
        }
    )
}
Создайте новый клиентский объект WebView и внедрите JavaScript на веб-страницу:
val passkeyWebListener = PasskeyWebListener(activity, coroutineScope, credentialManagerHandler)
val webViewClient = object : WebViewClient() {
    override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
        super.onPageStarted(view, url, favicon)
        webView.evaluateJavascript(PasskeyWebListener.INJECTED_VAL, null)
    }
}
webView.webViewClient = webViewClient
Настройте веб-прослушиватель сообщений
 Чтобы разрешить отправку сообщений между JavaScript и приложением Android, настройте веб-прослушиватель сообщений с помощью метода WebViewCompat.addWebMessageListener .
val rules = setOf("*")
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
    WebViewCompat.addWebMessageListener(
        webView, PasskeyWebListener.INTERFACE_NAME,
        rules, passkeyWebListener
    )
}
Веб-интеграция
Чтобы узнать, как создать веб-интеграцию, создайте ключ доступа для входа без пароля и войдите в систему с помощью ключа доступа через автозаполнение форм .
Тестирование и развертывание
Тщательно протестируйте весь процесс в контролируемой среде, чтобы обеспечить надлежащую связь между приложением Android, веб-страницей и бэкэндом.
Разверните интегрированное решение в рабочей среде, убедившись, что бэкенд может обрабатывать входящие запросы на регистрацию и аутентификацию. Код бэкенда должен генерировать начальный JSON-код для процессов регистрации (create) и аутентификации (get). Он также должен выполнять валидацию и верификацию ответов, полученных от веб-страницы.
Убедитесь, что реализация соответствует рекомендациям UX .
Важные примечания
-  Используйте предоставленный код JavaScript для обработки операций navigator.credentials.create()иnavigator.credentials.get().
-  Класс PasskeyWebListener— это мост между приложением Android и кодом JavaScript в WebView. Он отвечает за передачу сообщений, взаимодействие и выполнение необходимых действий.
- Адаптируйте предоставленные фрагменты кода в соответствии со структурой вашего проекта, соглашениями об именовании и любыми конкретными требованиями, которые могут у вас возникнуть.
- Перехватывайте ошибки на стороне собственного приложения и отправляйте их обратно на сторону JavaScript.
Следуя этому руководству и интегрировав API диспетчера учетных данных в свое приложение Android, использующее WebView, вы сможете обеспечить пользователям безопасный и бесперебойный вход в систему с использованием ключа доступа, эффективно управляя их учетными данными.
