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.

Conditions préalables

Pour utiliser l'API Gestionnaire d'identifiants, suivez la procédure décrite dans la section Conditions préalables du guide dédié 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.

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
}

Dans PasskeyWebListener, implémentez la logique pour les requêtes et les réponses, comme décrit dans les sections suivantes.

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 :

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

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.

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

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 :

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

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

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

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.

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

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.