Nutzer mit WebView authentifizieren

In diesem Dokument wird beschrieben, wie Sie die Credential Manager API in eine Android-App einbinden, die WebView verwendet.

Übersicht

Bevor Sie mit der Integration beginnen, ist es wichtig, den Kommunikationsfluss zwischen nativem Android-Code, einer Webkomponente, die in einer WebView gerendert wird und die Authentifizierung Ihrer App verwaltet, und einem Backend zu verstehen. Der Ablauf umfasst die Registrierung (Anmeldedaten erstellen) und die Authentifizierung (vorhandene Anmeldedaten abrufen).

Registrierung (Passkey erstellen)

  1. Das Backend generiert das erste Registrierungs-JSON und sendet es an die Webseite, die im WebView gerendert wird.
  2. Auf der Webseite wird navigator.credentials.create() verwendet, um neue Anmeldedaten zu registrieren. Sie verwenden das eingefügte JavaScript, um diese Methode in einem späteren Schritt zu überschreiben und die Anfrage an die Android-App zu senden.
  3. Die Android-App verwendet die Credential Manager API, um die Anmeldedatenanfrage zu erstellen und damit createCredential.
  4. Die Credential Manager API gibt die Anmeldedaten für den öffentlichen Schlüssel an die App weiter.
  5. Die App sendet die Anmeldedaten mit dem öffentlichen Schlüssel zurück an die Webseite, damit das eingefügte JavaScript die Antworten parsen kann.
  6. Die Webseite sendet den öffentlichen Schlüssel an das Backend, das ihn verifiziert und speichert.
Diagramm zum Ablauf der Passkey-Registrierung
Abbildung 1: Der Ablauf der Passkey-Registrierung.

Authentifizierung (Passkey abrufen)

  1. Das Backend generiert Authentifizierungs-JSON, um die Anmeldedaten abzurufen, und sendet diese an die Webseite, die im WebView-Client gerendert wird.
  2. Auf der Webseite wird navigator.credentials.get verwendet. Verwenden Sie das eingefügte JavaScript, um diese Methode zu überschreiben und die Anfrage an die Android-App weiterzuleiten.
  3. Die App ruft die Anmeldedaten mit der Credential Manager API durch Aufrufen von getCredential ab.
  4. Die Credential Manager API gibt die Anmeldedaten an die App zurück.
  5. Die App ruft die digitale Signatur des privaten Schlüssels ab und sendet sie an die Webseite, damit das eingefügte JavaScript die Antworten parsen kann.
  6. Anschließend wird sie an den Server gesendet, der die digitale Signatur mit dem öffentlichen Schlüssel überprüft.
Diagramm zum Passkey-Authentifizierungsablauf
Abbildung 2. Der Ablauf der Passkey-Authentifizierung.

Derselbe Ablauf kann für Passwörter oder föderierte Identitätssysteme verwendet werden.

Voraussetzungen

Wenn Sie die Credential Manager API verwenden möchten, führen Sie die Schritte im Abschnitt Voraussetzungen des Credential Manager-Leitfadens aus und achten Sie darauf, dass Sie Folgendes tun:

JavaScript-Kommunikation

Damit JavaScript in einem WebView und nativer Android-Code miteinander kommunizieren können, müssen Sie Nachrichten zwischen den beiden Umgebungen senden und Anfragen verarbeiten. Dazu fügen Sie benutzerdefinierten JavaScript-Code in eine WebView ein. So können Sie das Verhalten von Webinhalten ändern und mit nativem Android-Code interagieren.

JavaScript-Einschleusung

Der folgende JavaScript-Code stellt die Kommunikation zwischen der WebView und der Android-App her. Er überschreibt die Methoden navigator.credentials.create() und navigator.credentials.get(), die von der WebAuthn API für die oben beschriebenen Registrierungs- und Authentifizierungsabläufe verwendet werden.

Verwenden Sie die minimierte Version dieses JavaScript-Codes in Ihrer Anwendung.

Listener für Passkeys erstellen

Richten Sie eine PasskeyWebListener-Klasse ein, die die Kommunikation mit JavaScript übernimmt. Diese Klasse sollte von WebViewCompat.WebMessageListener abgeleitet werden. Diese Klasse empfängt Nachrichten von JavaScript und führt die erforderlichen Aktionen in der Android-App aus.

In den folgenden Abschnitten wird die Struktur der PasskeyWebListener-Klasse sowie die Verarbeitung von Anfragen und Antworten beschrieben.

Authentifizierungsanfrage verarbeiten

Wenn Anfragen für WebAuthn-Vorgänge vom Typ navigator.credentials.create() oder navigator.credentials.get() verarbeitet werden sollen, wird die Methode onPostMessage der Klasse PasskeyWebListener aufgerufen, wenn der JavaScript-Code eine Nachricht an die Android-App sendet:

// 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)};
        """
  }

Informationen zu handleCreateFlow und handleGetFlow finden Sie im Beispiel auf GitHub.

Antwort verarbeiten

Um die Antworten zu verarbeiten, die von der nativen App an die Webseite gesendet werden, fügen Sie JavaScriptReplyProxy in JavaScriptReplyChannel ein.

// 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?)
}

Achten Sie darauf, alle Fehler aus der nativen App abzufangen und an die JavaScript-Seite zurückzugeben.

In WebView einbinden

In diesem Abschnitt wird beschrieben, wie Sie Ihre WebView-Integration einrichten.

WebView initialisieren

Initialisieren Sie in der Aktivität Ihrer Android-App ein WebView und richten Sie ein zugehöriges WebViewClient ein. WebViewClient übernimmt die Kommunikation mit dem JavaScript-Code, der in WebView eingefügt wird.

WebView einrichten und Credential Manager aufrufen:

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)
    }
  }
  )
}

Erstellen Sie ein neues WebView-Clientobjekt und fügen Sie JavaScript in die Webseite ein:

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

Webnachrichten-Listener einrichten

Damit Nachrichten zwischen JavaScript und der Android-App gesendet werden können, müssen Sie mit der Methode WebViewCompat.addWebMessageListener einen Web Message Listener einrichten.

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

Web integration

Informationen zum Erstellen einer Web-Integration für die Kaufabwicklung finden Sie unter Passkey für die passwortlose Anmeldung erstellen und Mit einem Passkey über das automatische Ausfüllen von Formularen anmelden.

Testen und Bereitstellen

Testen Sie den gesamten Ablauf gründlich in einer kontrollierten Umgebung, um eine ordnungsgemäße Kommunikation zwischen der Android-App, der Webseite und dem Backend sicherzustellen.

Stellen Sie die integrierte Lösung in der Produktion bereit und sorgen Sie dafür, dass das Backend eingehende Registrierungs- und Authentifizierungsanfragen verarbeiten kann. Der Backend-Code sollte das erste JSON für die Registrierung (create) und die Authentifizierung (get) generieren. Außerdem sollten die von der Webseite empfangenen Antworten validiert und bestätigt werden.

Prüfen Sie, ob die Implementierung den UX-Empfehlungen entspricht.

Wichtige Hinweise

  • Verwenden Sie den bereitgestellten JavaScript-Code, um navigator.credentials.create()- und navigator.credentials.get()-Vorgänge zu verarbeiten.
  • Die Klasse PasskeyWebListener ist die Brücke zwischen der Android-App und dem JavaScript-Code in der WebView. Sie kümmert sich um die Nachrichtenübermittlung, die Kommunikation und die Ausführung der erforderlichen Aktionen.
  • Passen Sie die bereitgestellten Code-Snippets an die Struktur, die Namenskonventionen und alle spezifischen Anforderungen Ihres Projekts an.
  • Fehler auf der Seite der nativen App abfangen und an die JavaScript-Seite zurücksenden.

Wenn Sie dieser Anleitung folgen und die Credential Manager API in Ihre Android-App mit WebView einbinden, können Sie Ihren Nutzern eine sichere und nahtlose Anmeldung mit Passkeys ermöglichen und gleichzeitig ihre Anmeldedaten effektiv verwalten.