WebView와 인증 관리자 통합

이 문서에서는 Credential Manager API를 WebView를 사용하는 Android 앱과 통합하는 방법을 설명합니다.

개요

통합 프로세스를 자세히 알아보기 전에 네이티브 Android 코드, 앱의 인증을 관리하는 WebView 내에서 렌더링된 웹 구성요소, 백엔드 간의 통신 흐름을 파악하는 것이 중요합니다. 이 흐름에는 등록(사용자 인증 정보 만들기) 및 인증(기존 사용자 인증 정보 가져오기)이 포함됩니다.

등록(패스키 만들기)

  1. 백엔드에서는 초기 등록 JSON을 생성하여 WebView 내에서 렌더링된 웹페이지로 전송합니다.
  2. 웹페이지는 navigator.credentials.create()를 사용하여 새 사용자 인증 정보를 등록합니다. 삽입된 JavaScript를 사용하여 이후 단계에서 이 메서드를 재정의해 요청을 Android 앱에 전송합니다.
  3. Android 앱은 Credential Manager API를 사용하여 사용자 인증 정보 요청을 구성하고 이를 createCredential에 사용합니다.
  4. Credential Manager API는 공개 키 사용자 인증 정보를 앱과 공유합니다.
  5. 앱은 삽입된 JavaScript가 응답을 파싱할 수 있도록 공개 키 사용자 인증 정보를 웹페이지로 다시 보냅니다.
  6. 웹페이지는 공개 키를 백엔드로 전송하고 백엔드에서는 공개 키를 확인하고 저장합니다.
패스키 등록 흐름을 보여주는 차트
그림 1. 패스키 등록 흐름

인증(패스키 가져오기)

  1. 백엔드에서는 인증 JSON을 생성하여 사용자 인증 정보를 가져오고 이를 WebView 클라이언트에서 렌더링되는 웹페이지로 전송합니다.
  2. 웹페이지에서는 navigator.credentials.get을 사용합니다. 삽입된 JavaScript를 사용하여 이 메서드를 재정의해 요청을 Android 앱으로 리디렉션합니다.
  3. 앱은 getCredential 호출을 통해 Credential Manager API를 사용하여 사용자 인증 정보를 가져옵니다.
  4. Credential Manager API는 앱에 사용자 인증 정보를 반환합니다.
  5. 앱은 삽입된 JavaScript가 응답을 파싱할 수 있도록 비공개 키의 디지털 서명을 가져와 웹페이지로 보냅니다.
  6. 그러면 웹페이지에서는 공개 키로 디지털 서명을 확인하는 서버로 이를 전송합니다.
패스키 인증 흐름을 보여주는 차트
그림 1. 패스키 인증 흐름

비밀번호 또는 제휴 ID 시스템에도 동일한 흐름을 사용할 수 있습니다.

기본 요건

Credential Manager API를 사용하려면 인증 관리자 가이드의 기본 요건 섹션에 설명된 단계를 완료하고 다음을 실행해야 합니다.

JavaScript 통신

WebView의 JavaScript와 네이티브 Android 코드가 서로 통신할 수 있도록 하려면 두 환경 간에 메시지를 전송하고 요청을 처리해야 합니다. 이를 위해 맞춤 JavaScript 코드를 WebView에 삽입하세요. 이렇게 하면 웹 콘텐츠의 동작을 수정하고 네이티브 Android 코드와 상호작용할 수 있습니다.

JavaScript 삽입

다음 JavaScript 코드는 WebView와 Android 앱 간의 통신을 설정합니다. 앞서 설명한 등록 및 인증 흐름을 위해 WebAuthn API에서 사용하는 navigator.credentials.create()navigator.credentials.get() 메서드를 재정의합니다.

애플리케이션에서 이 JavaScript 코드의 축소된 버전을 사용하세요.

패스키 리스너 만들기

JavaScript와의 통신을 처리하는 PasskeyWebListener 클래스를 설정합니다. 이 클래스는 WebViewCompat.WebMessageListener에서 상속받아야 합니다. 이 클래스는 JavaScript에서 메시지를 수신하고 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
}

PasskeyWebListener 내에서 요청 및 응답 로직을 구현합니다(다음 섹션 참고).

인증 요청 처리

WebAuthn navigator.credentials.create() 또는 navigator.credentials.get() 작업 요청을 처리하기 위해 JavaScript 코드가 Android 앱에 메시지를 전송할 때 PasskeyWebListener 클래스의 onPostMessage 메서드가 호출됩니다.

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

handleCreateFlowhandleGetFlowGitHub의 예시를 참고하세요.

응답 처리

네이티브 앱에서 웹페이지로 전송되는 응답을 처리하려면 JavaScriptReplyChannel 내에 JavaScriptReplyProxy를 추가합니다.

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

네이티브 앱에서 오류를 포착하여 JavaScript 측으로 다시 보내야 합니다.

WebView와 통합

이 섹션에서는 WebView 통합을 설정하는 방법을 설명합니다.

WebView 초기화

Android 앱의 활동에서 WebView를 초기화하고 함께 제공되는 WebViewClient를 설정합니다. WebViewClientWebView에 삽입된 JavaScript 코드와의 통신을 처리합니다.

다음과 같이 WebView를 설정하고 인증 관리자를 호출합니다.

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

새 WebView 클라이언트 객체를 만들고 JavaScript를 웹페이지에 삽입합니다.

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

웹 메시지 리스너 설정

JavaScript와 Android 앱 간에 메시지가 게시되도록 하려면 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
  )
}

웹 통합

웹 통합을 빌드하는 방법을 알아보려면 비밀번호가 없는 로그인의 패스키 생성양식 자동 완성을 통해 패스키로 로그인을 참고하세요.

테스트 및 배포

통제된 환경에서 전체 흐름을 철저히 테스트하여 Android 앱과 웹페이지, 백엔드 간에 적절한 통신이 이루어지도록 하세요.

들어오는 등록 및 인증 요청을 백엔드에서 처리할 수 있도록 통합 솔루션을 프로덕션에 배포합니다. 백엔드 코드는 등록(만들기) 및 인증(가져오기) 프로세스를 위해 초기 JSON을 생성해야 합니다. 또한 웹페이지에서 수신된 응답의 유효성 검사 및 확인을 처리해야 합니다.

구현이 UX 권장사항과 일치하는지 확인합니다.

중요

  • 제공된 JavaScript 코드를 사용하여 navigator.credentials.create()navigator.credentials.get() 작업을 처리합니다.
  • PasskeyWebListener 클래스는 Android 앱과 WebView의 JavaScript 코드 간의 가교 역할을 합니다. 메시지 전달, 통신, 필요한 작업 실행을 처리합니다.
  • 제공된 코드 스니펫을 프로젝트의 구조, 이름 지정 규칙, 특정 요구사항에 맞게 조정합니다.
  • 네이티브 앱 측에서 오류를 포착하여 JavaScript 측으로 다시 보냅니다.

이 가이드에 따라 Credential Manager API를 WebView를 사용하는 Android 앱에 통합하면 사용자 인증 정보를 효과적으로 관리하면서 사용자에게 패스키가 지원되는 안전하고 원활한 로그인 환경을 제공할 수 있습니다.