Autenticar usuários com a WebView

Este documento descreve como integrar o Gerenciador de credenciais a um app Android que usa a WebView.

Visão geral

Antes de se aprofundar no processo de integração, é importante entender o fluxo de comunicação entre o código nativo do Android, um componente da Web renderizado em uma WebView que gerencia a autenticação do app e um back-end. O fluxo envolve o registro (criação de credenciais) e a autenticação (recebimento de credenciais existentes).

Registro (criar uma chave de acesso)

  1. O back-end gera o arquivo JSON de registro inicial e o envia para a página da Web renderizada na WebView.
  2. A página da Web usa navigator.credentials.create() para registrar novas credenciais. Você vai usar o JavaScript injetado para substituir esse método em uma etapa posterior para enviar a solicitação ao app Android.
  3. O app Android usa o Gerenciador de credenciais para criar a solicitação de credencial e usá-la para createCredential.
  4. O Gerenciador de credenciais compartilha a credencial de chave pública com o app.
  5. O app envia a credencial de chave pública de volta à página da Web para que o JavaScript injetado possa analisar as respostas.
  6. A página da Web envia a chave pública para o back-end, que a verifica e salva.
Gráfico mostrando o fluxo de registro da chave de acesso
Figura 1. Fluxo de registro da chave de acesso.

Autenticação (receber uma chave de acesso)

  1. O back-end gera um arquivo JSON de autenticação para receber a credencial e o envia para a página da Web renderizada no cliente da WebView.
  2. A página da Web usa navigator.credentials.get. Use o JavaScript injetado para substituir esse método para redirecionar a solicitação para o app Android.
  3. O app recupera a credencial ao chamar getCredential no Gerenciador de credenciais.
  4. O Gerenciador de credenciais retorna a credencial ao app.
  5. O app recebe a assinatura digital da chave privada e a envia à página da Web para que o JavaScript injetado possa analisar as respostas.
  6. Em seguida, a página da Web a envia ao servidor, que verifica a assinatura digital com a chave pública.
Gráfico mostrando o fluxo de autenticação da chave de acesso
Figura 2. Fluxo de autenticação da chave de acesso.

O mesmo fluxo pode ser usado para senhas ou sistemas de identidade federados.

Pré-requisitos

Para usar o Gerenciador de credenciais, conclua as etapas descritas na seção de pré-requisitos do guia da API e faça o seguinte:

Comunicação com JavaScript

Para permitir que o JavaScript em uma WebView e o código nativo do Android se comuniquem entre si, envie mensagens e processe solicitações entre os dois ambientes. Para fazer isso, injete um código JavaScript personalizado em uma WebView. Isso permite modificar o comportamento do conteúdo da Web e interagir com o código nativo do Android.

Injeção de JavaScript

O código JavaScript a seguir estabelece a comunicação entre a WebView e o app Android. Ele substitui os métodos navigator.credentials.create() e navigator.credentials.get() usados pela API WebAuthn para os fluxos de registro e autenticação descritos anteriormente.

Use a versão reduzida desse código JavaScript no seu aplicativo.

Criar um listener para chaves de acesso

Configure uma classe PasskeyWebListener para processar a comunicação com o JavaScript. Ela precisa herdar de WebViewCompat.WebMessageListener. Essa classe recebe mensagens do JavaScript e executa as ações necessárias no app Android.

// 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
// 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, implemente a lógica para solicitações e respostas, conforme descrito nas seções a seguir.

Processar a solicitação de autenticação

Para processar solicitações de operações navigator.credentials.create() ou navigator.credentials.get() da WebAuthn, o método onPostMessage da classe PasskeyWebListener é chamado quando o código JavaScript envia uma mensagem ao app Android:

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"
 
}
}
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 e handleGetFlow, consulte o exemplo no GitHub (em inglês).

Processar a resposta

Para gerenciar as respostas enviadas do app nativo à página da Web, adicione JavaScriptReplyProxy ao JavaScriptReplyChannel.

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

Capture todos os erros do app nativo e envie-os de volta para o lado do JavaScript.

Integrar com a WebView

Esta seção descreve como configurar sua integração com a WebView.

Inicializar a WebView

Na atividade do app Android, inicialize uma WebView e configure um WebViewClient complementar. O WebViewClient processa a comunicação com o código JavaScript injetado na WebView.

Configure a WebView e chame o Gerenciador de credenciais:

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

Crie um novo objeto de cliente da WebView e injete o código JavaScript na página da Web:

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

Configurar um listener de mensagens da Web

Para permitir que mensagens sejam postadas entre o JavaScript e o app Android, configure um listener de mensagens da Web com o método WebViewCompat.addWebMessageListener.

val rules = setOf("*")
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
 
WebViewCompat.addWebMessageListener(
    webView
, PasskeyWebListener.INTERFACE_NAME, rules, passkeyWebListener
 
)
}
Set<String> rules = new HashSet<>(Arrays.asList("*"));

if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
 
WebViewCompat.addWebMessageListener(
    webView
, PasskeyWebListener.INTERFACE_NAME, rules, passkeyWebListener
 
)
}

Integração com a Web

Para aprender a criar integração com a Web, confira as seções Criar uma chave de acesso para logins sem senha e Fazer login com uma chave de acesso usando o preenchimento automático de formulários.

Teste e implantação

Teste todo o fluxo em um ambiente controlado para garantir uma comunicação adequada entre o app Android, a página da Web e o back-end.

Implante a solução integrada na produção, garantindo que o back-end possa processar as solicitações de registro e autenticação. O código de back-end precisa gerar o arquivo JSON inicial para os processos de registro (criação) e autenticação (recebimento). Ele também precisa processar a validação e verificação das respostas recebidas da página da Web.

Verifique se a implementação corresponde às recomendações de UX.

Observações importantes

  • Use o código JavaScript fornecido para processar as operações navigator.credentials.create() e navigator.credentials.get().
  • A classe PasskeyWebListener é a ponte entre o app Android e o código JavaScript na WebView. Ela processa a transmissão de mensagens, a comunicação e a execução das ações necessárias.
  • Adapte os snippets de código fornecidos à estrutura, às convenções de nomenclatura e aos requisitos específicos do projeto.
  • Detecte erros no app nativo e envie-os de volta para o lado do JavaScript.

Ao seguir este guia e integrar o Gerenciador de credenciais ao seu app Android que usa a WebView, você pode oferecer aos usuários uma experiência de login com chaves de acesso segura e integrada, bem como gerenciar as credenciais de maneira eficaz.