Authentifier les utilisateurs avec WebView

Ce document explique comment intégrer l'API Gestionnaire d'identifiants à une application Android qui utilise WebView.

Présentation

Avant d'explorer le processus d'intégration, il est important de comprendre le flux de communication entre le code Android natif, un composant Web affiché dans une WebView qui gère l'authentification de votre application et un backend. Ce flux comprend l'inscription (création d'identifiants) et l'authentification (obtention d'identifiants existants).

Inscription (création d'une clé d'accès)

  1. Le backend génère un fichier JSON d'inscription initial et l'envoie à la page Web affichée dans la WebView.
  2. La page Web utilise navigator.credentials.create() pour enregistrer de nouveaux identifiants. Vous utiliserez le code JavaScript injecté pour remplacer cette méthode lors d'une prochaine étape, afin d'envoyer la requête à l'application Android.
  3. L'application Android utilise l'API Gestionnaire d'identifiants pour créer la requête d'identification et l'utiliser pour createCredential.
  4. L'API Gestionnaire d'identifiants partage les identifiants de clé publique avec l'application.
  5. L'application renvoie les identifiants de clé publique à la page Web afin que le code JavaScript injecté puisse analyser les réponses.
  6. La page Web envoie la clé publique au backend, qui la vérifie et l'enregistre.
Graphique illustrant le flux d'enregistrement d'une clé d'accès
Figure 1. Flux d'enregistrement d'une clé d'accès

Authentification (obtention d'une clé d'accès)

  1. Le backend génère un fichier JSON d'authentification pour obtenir les identifiants et l'envoie à la page Web affichée dans le client WebView.
  2. La page Web utilise navigator.credentials.get. Utilisez le code JavaScript injecté pour remplacer cette méthode et rediriger la requête vers l'application Android.
  3. L'application récupère les identifiants à l'aide de l'API Gestionnaire d'identifiants en appelant getCredential.
  4. L'API Gestionnaire d'identifiants renvoie les identifiants à l'application.
  5. L'application obtient la signature numérique de la clé privée et l'envoie à la page Web afin que le code JavaScript injecté puisse analyser les réponses.
  6. La page Web l'envoie ensuite au serveur qui vérifie la signature numérique à l'aide de la clé publique.
Graphique illustrant le flux d'authentification par clé d'accès
Figure 2. Flux d'authentification par clé d'accès

Le même flux peut être utilisé pour les mots de passe ou les systèmes d'identité fédérée.

Prérequis

Pour utiliser l'API Credential Manager, suivez la procédure décrite dans la section Conditions préalables du guide Credential Manager et effectuez les opérations suivantes :

Communication JavaScript

Pour autoriser JavaScript dans une WebView et le code Android natif à communiquer entre eux, vous devez envoyer des messages et gérer les requêtes entre les deux environnements. Pour ce faire, injectez un code JavaScript personnalisé dans une WebView. Cela vous permet de modifier le comportement du contenu Web et d'interagir avec le code Android natif.

Injection JavaScript

Le code JavaScript suivant établit la communication entre la WebView et l'application Android. Il remplace les méthodes navigator.credentials.create() et navigator.credentials.get() utilisées par l'API WebAuthn pour les flux d'inscription et d'authentification décrits précédemment.

Utilisez la version réduite de ce code JavaScript dans votre application.

Créer un écouteur pour les clés d'accès

Configurez une classe PasskeyWebListener qui gère la communication avec JavaScript. Cette classe doit hériter de WebViewCompat.WebMessageListener. Cette classe reçoit des messages de JavaScript et effectue les actions nécessaires dans l'application Android.

Les sections suivantes décrivent la structure de la classe PasskeyWebListener, ainsi que le traitement des requêtes et des réponses.

Gérer la requête d'authentification

Pour gérer les requêtes pour les opérations WebAuthn navigator.credentials.create() ou navigator.credentials.get(), la méthode onPostMessage de la classe PasskeyWebListener est appelée lorsque le code JavaScript envoie un message à l'application 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)};
        """
  }

Pour handleCreateFlow et handleGetFlow, reportez-vous à l'exemple sur GitHub.

Gérer la réponse

Pour gérer les réponses envoyées depuis l'application native à la page Web, ajoutez JavaScriptReplyProxy dans 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?)
}

Veillez à détecter toutes les erreurs dans l'application native et à les renvoyer côté JavaScript.

Intégrer à la WebView

Cette section explique comment configurer l'intégration de votre WebView.

Initialiser la WebView

Dans l'activité de votre application Android, initialisez une WebView et configurez un WebViewClient associé. Le WebViewClient gère la communication avec le code JavaScript injecté dans la WebView.

Configurez la WebView et appelez le Gestionnaire d'identifiants :

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

Créez un objet client WebView et injectez du code JavaScript dans la page Web :

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

Configurer un écouteur de messages Web

Pour autoriser la publication de messages entre JavaScript et l'application Android, configurez un écouteur de messages Web avec la méthode WebViewCompat.addWebMessageListener.

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

Intégration Web

Pour découvrir comment créer une intégration Web, consultez Créer une clé d'accès pour les connexions sans mot de passe et Se connecter avec une clé d'accès via la saisie automatique des formulaires.

Tests et déploiement

Testez l'ensemble du flux de manière approfondie dans un environnement contrôlé afin de garantir une communication appropriée entre l'application Android, la page Web et le backend.

Déployez la solution intégrée en production, en vous assurant que le backend peut gérer les requêtes d'inscription et d'authentification entrantes. Le code backend doit générer un fichier JSON initial pour les processus d'inscription (create) et d'authentification (get). Il doit également gérer la validation et la vérification des réponses reçues de la page Web.

Vérifiez que l'implémentation correspond aux recommandations relatives à l'expérience utilisateur.

Remarques importantes

  • Utilisez le code JavaScript fourni pour gérer les opérations navigator.credentials.create() et navigator.credentials.get().
  • La classe PasskeyWebListener constitue la passerelle entre l'application Android et le code JavaScript dans la WebView. Elle gère la transmission des messages, la communication et l'exécution des actions requises.
  • Adaptez les extraits de code fournis à la structure de votre projet, aux conventions de dénomination et à vos éventuelles exigences spécifiques.
  • Détectez les erreurs côté application native et renvoyez-les vers JavaScript.

En suivant ce guide et en intégrant l'API Gestionnaire d'identifiants à votre application Android qui utilise une WebView, vous pouvez offrir à vos utilisateurs une expérience de connexion sécurisée et fluide via des clés d'accès, tout en gérant efficacement leurs identifiants.