Cómo autenticar usuarios con WebView

En este documento, se describe cómo integrar la API de Credential Manager en una app para Android que usa WebView.

Descripción general

Antes de sumergirte en el proceso de integración, es importante comprender el flujo de comunicación entre el código nativo de Android, un componente web renderizado dentro de un WebView que administra la autenticación de tu app y un backend. El flujo incluye el registro (la creación de credenciales) y la autenticación (la obtención de credenciales existentes).

Registro (crea una llave de acceso)

  1. El backend genera un JSON de registro inicial y lo envía a la página web renderizada dentro del WebView.
  2. La página web usa navigator.credentials.create() para registrar credenciales nuevas. Usarás el código JavaScript insertado para anular este método en un paso posterior y enviar la solicitud a la app para Android.
  3. La app para Android usa la API de Credential Manager para crear la solicitud de credenciales y usarla en createCredential.
  4. La API de Credential Manager comparte la credencial de clave pública con la app.
  5. La app envía la credencial de clave pública de vuelta a la página web para que el JavaScript insertado pueda analizar las respuestas.
  6. La página web envía la clave pública al backend, que la verifica y la guarda.
Gráfico que muestra el flujo de registro de la llave de acceso
Figura 1: El flujo de registro de la llave de acceso

Autenticación (obtén una llave de acceso)

  1. El backend genera un JSON de autenticación para obtener la credencial y la envía a la página web que se renderiza en el cliente del WebView.
  2. La página web usa navigator.credentials.get. Usa el código JavaScript insertado para anular este método y redireccionar la solicitud a la app para Android.
  3. La app recupera la credencial con la API de Credential Manager llamando a getCredential.
  4. La API de Credential Manager muestra la credencial en la app.
  5. La app obtiene la firma digital de la clave privada y la envía a la página web para que el JavaScript insertado pueda analizar las respuestas.
  6. Luego, la página web la envía al servidor que verifica la firma digital con la clave pública.
Gráfico que muestra el flujo de autenticación de la llave de acceso
Figura 2: El flujo de autenticación de la llave de acceso

El mismo flujo podría usarse para contraseñas o sistemas de identidad federada.

Requisitos previos

Para usar la API de Credential Manager, completa los pasos descritos en la sección de requisitos previos de la guía del Administrador de credenciales y asegúrate de hacer lo siguiente:

Comunicación de JavaScript

Para permitir que JavaScript en un WebView y el código nativo de Android se comuniquen entre sí, debes enviar mensajes y controlar las solicitudes entre los dos entornos. Para ello, inserta código de JavaScript personalizado en un WebView. De esta manera, puedes modificar el comportamiento del contenido web e interactuar con el código nativo de Android.

Inserción de JavaScript

El siguiente código JavaScript establece la comunicación entre WebView y la app para Android. Anula los métodos navigator.credentials.create() y navigator.credentials.get() que usa la API de WebAuthn para los flujos de registro y autenticación que se describieron antes.

Usa la versión reducida de este código de JavaScript en tu aplicación.

Crea un objeto de escucha para las llaves de acceso

Configura una clase PasskeyWebListener que controle la comunicación con JavaScript. Esta clase se debe heredar de WebViewCompat.WebMessageListener. Esta clase recibe mensajes de JavaScript y realiza las acciones necesarias en la app para 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
}

Dentro de PasskeyWebListener, implementa la lógica para las solicitudes y respuestas, como se describe en las siguientes secciones.

Controla la solicitud de autenticación

Para controlar las solicitudes de las operaciones navigator.credentials.create() o navigator.credentials.get() de WebAuthn, se llama al método onPostMessage de la clase PasskeyWebListener cuando el código JavaScript envía un mensaje a la app para 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();
      }
    }
  }
}

Para handleCreateFlow y handleGetFlow, consulta el ejemplo en GitHub.

Cómo controlar la respuesta

Para controlar las respuestas que se envían desde la app nativa a la página web, agrega JavaScriptReplyProxy dentro de 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);
  }
}

Asegúrate de detectar los errores de la app nativa y enviarlos de vuelta a JavaScript.

Cómo realizar la integración con WebView

En esta sección, se describe cómo configurar tu integración de WebView.

Cómo inicializar WebView

En la actividad de tu app para Android, inicializa un WebView y configura un WebViewClient complementario. WebViewClient controla la comunicación con el código JavaScript insertado en WebView.

Configura WebView y llama al Administrador de credenciales:

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

Crea un nuevo objeto de cliente de WebView y, luego, inserta JavaScript en la página 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);

Configura un objeto de escucha de mensajes web

Para permitir que se publiquen mensajes entre JavaScript y la app para Android, configura un objeto de escucha de mensajes web con el método 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
  )
}

Integración web

Si quieres aprender a compilar la confirmación de la compra con la integración web, Crea una llave de acceso para accesos sin contraseña y Accede con una llave de acceso mediante el autocompletado de formularios.

Pruebas e implementación

Prueba todo el flujo en un entorno controlado para garantizar una comunicación adecuada entre la app para Android, la página web y el backend.

Implementa la solución integrada en la producción y asegúrate de que el backend pueda controlar las solicitudes entrantes de registro y autenticación. El código de backend debe generar un JSON inicial para los procesos de registro (creación) y autenticación (get). También debe controlar la validación y la verificación de las respuestas recibidas de la página web.

Verifica que la implementación cumpla con las recomendaciones de UX.

Notas importantes:

  • Usa el código JavaScript proporcionado para controlar las operaciones navigator.credentials.create() y navigator.credentials.get().
  • La clase PasskeyWebListener es el puente entre la app para Android y el código JavaScript en WebView. Controla el envío de mensajes, la comunicación y la ejecución de las acciones requeridas.
  • Adapta los fragmentos de código proporcionados para que se ajusten a la estructura de tu proyecto, a las convenciones de nombres y a cualquier requisito específico que puedas tener.
  • Detecta errores en la app nativa y envíalos de vuelta a JavaScript.

Si sigues esta guía e integras la API de Credential Manager en tu app para Android que usa WebView, podrás brindar a los usuarios una experiencia de acceso segura y fluida con llave de acceso, a la vez que administras sus credenciales de manera eficaz.