Integra Gestore delle credenziali con WebView

Questo documento descrive come integrare l'API Credential Manager con un'app per Android che utilizza WebView.

Panoramica

Prima di approfondire il processo di integrazione, è importante comprendere il flusso di comunicazione tra il codice nativo di Android, un componente web visualizzato in un componente WebView che gestisce l'autenticazione dell'app, e un backend. Il flusso prevede la registrazione (creazione di credenziali) e l'autenticazione (ottenimento delle credenziali esistenti).

Registrazione (crea una passkey)

  1. Il backend genera un JSON di registrazione iniziale e lo invia alla pagina web visualizzata all'interno del componente WebView.
  2. La pagina web utilizza navigator.credentials.create() per registrare nuove credenziali. Utilizzerai il codice JavaScript inserito per eseguire l'override di questo metodo in un passaggio successivo per inviare la richiesta all'app per Android.
  3. L'app Android utilizza l'API Credential Manager per creare la richiesta di credenziali e usarla per createCredential.
  4. L'API Credential Manager condivide la credenziale della chiave pubblica con l'app.
  5. L'app invia la credenziale della chiave pubblica alla pagina web in modo che il codice JavaScript inserito possa analizzare le risposte.
  6. La pagina web invia la chiave pubblica al backend, che verifica e salva la chiave pubblica.
Grafico che mostra il flusso di registrazione delle passkey
Figura 1. Il flusso di registrazione delle passkey.

Autenticazione (ottieni una passkey)

  1. Il backend genera JSON di autenticazione per ottenere le credenziali e lo invia alla pagina web visualizzata nel client WebView.
  2. La pagina web utilizza navigator.credentials.get. Utilizza il codice JavaScript inserito per eseguire l'override di questo metodo e reindirizzare la richiesta all'app per Android.
  3. L'app recupera la credenziale utilizzando l'API Credential Manager chiamando getCredential.
  4. L'API Credential Manager restituisce la credenziale all'app.
  5. L'app riceve la firma digitale della chiave privata e la invia alla pagina web in modo che il codice JavaScript inserito possa analizzare le risposte.
  6. Poi la pagina web la invia al server che verifica la firma digitale con la chiave pubblica.
Grafico che mostra il flusso di autenticazione delle passkey
Figura 1. Il flusso di autenticazione delle passkey.

Potrebbe essere utilizzato lo stesso flusso per le password o i sistemi di identità federati.

Prerequisiti

Per utilizzare l'API Credential Manager, completa i passaggi descritti nella sezione dei prerequisiti della guida di Gestore delle credenziali e assicurati di svolgere le seguenti operazioni:

Comunicazione JavaScript

Per consentire a JavaScript in un componente WebView e al codice nativo di Android di comunicare tra loro, devi inviare messaggi e gestire le richieste tra i due ambienti. Per farlo, inserisci il codice JavaScript personalizzato in un componente WebView. In questo modo puoi modificare il comportamento dei contenuti web e interagire con il codice nativo di Android.

Iniezione JavaScript

Il seguente codice JavaScript stabilisce la comunicazione tra WebView e l'app per Android. Sostituisce i metodi navigator.credentials.create() e navigator.credentials.get() utilizzati dall'API WebAuthn per i flussi di registrazione e autenticazione descritti in precedenza.

Utilizza la versione minimizzata di questo codice JavaScript nella tua applicazione.

Crea un listener per le passkey

Configurare un classe PasskeyWebListener che gestisca la comunicazione con JavaScript. Questa classe deve ereditare da WebViewCompat.WebMessageListener. Questa classe riceve messaggi da JavaScript ed esegue le azioni necessarie nell'app per 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
}

All'interno di PasskeyWebListener, implementa la logica per richieste e risposte, come descritto nelle sezioni seguenti.

Gestire la richiesta di autenticazione

Per gestire le richieste per le operazioni WebAuthn navigator.credentials.create() o navigator.credentials.get(), il metodo onPostMessage della classe PasskeyWebListener viene chiamato quando il codice JavaScript invia un messaggio all'app per 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();
      }
    }
  }
}

Per handleCreateFlow e handleGetFlow, fai riferimento all'esempio su GitHub.

Gestire la risposta

Per gestire le risposte inviate dall'app nativa alla pagina web, aggiungi il JavaScriptReplyProxy all'interno di 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);
  }
}

Assicurati di individuare eventuali errori dell'app nativa e di inviarli di nuovo a JavaScript.

Integra con WebView

Questa sezione descrive come configurare l'integrazione di WebView.

Inizializzare il componente WebView

Nell'attività della tua app Android, inizializza un WebView e configura un WebViewClient associato. WebViewClient gestisce la comunicazione con il codice JavaScript inserito in WebView.

Configura WebView e chiama Gestore delle credenziali:

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 nuovo oggetto client WebView e inserisci codice JavaScript nella pagina 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);

Configurare un listener di messaggi web

Per consentire la pubblicazione di messaggi tra JavaScript e l'app per Android, configura un listener di messaggi web con il metodo 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
  )
}

Integrazione web

Per scoprire come creare una funzionalità di pagamento con integrazione web, crea una passkey per gli accessi senza password e accedi con una passkey tramite la compilazione automatica dei moduli.

Test e deployment

Testa l'intero flusso in un ambiente controllato per garantire una comunicazione corretta tra l'app per Android, la pagina web e il backend.

Esegui il deployment della soluzione integrata in produzione, assicurandoti che il backend possa gestire le richieste di registrazione e autenticazione in entrata. Il codice di backend deve generare JSON iniziale per i processi di registrazione (creazione) e autenticazione (get). Inoltre, deve gestire la convalida e la verifica delle risposte ricevute dalla pagina web.

Verifica che l'implementazione corrisponda ai consigli relativi all'UX.

Note importanti

  • Utilizza il codice JavaScript fornito per gestire le operazioni navigator.credentials.create() e navigator.credentials.get().
  • La classe PasskeyWebListener è il ponte tra l'app per Android e il codice JavaScript nella WebView. Gestisce la trasmissione dei messaggi, la comunicazione e l'esecuzione delle azioni richieste.
  • Adatta gli snippet di codice forniti alla struttura, alle convenzioni di denominazione e agli eventuali requisiti specifici del tuo progetto.
  • Individua gli errori sull'app nativa e inviali nuovamente a JavaScript.

Seguendo questa guida e integrando l'API Credential Manager nella tua app Android che utilizza WebView, puoi fornire ai tuoi utenti un'esperienza di accesso protetta e senza interruzioni tramite passkey, gestendo al contempo le loro credenziali in modo efficace.