이 문서에서는 Credential Manager API를 WebView를 사용하는 Android 앱과 통합하는 방법을 설명합니다.
개요
통합 프로세스를 자세히 알아보기 전에 네이티브 Android 코드, 앱의 인증을 관리하는 WebView 내에서 렌더링된 웹 구성요소, 백엔드 간의 통신 흐름을 파악하는 것이 중요합니다. 이 흐름에는 등록(사용자 인증 정보 만들기) 및 인증(기존 사용자 인증 정보 가져오기)이 포함됩니다.
등록(패스키 만들기)
- 백엔드에서는 초기 등록 JSON을 생성하여 WebView 내에서 렌더링된 웹페이지로 전송합니다.
- 웹페이지는
navigator.credentials.create()
를 사용하여 새 사용자 인증 정보를 등록합니다. 삽입된 JavaScript를 사용하여 이후 단계에서 이 메서드를 재정의해 요청을 Android 앱에 전송합니다. - Android 앱은 Credential Manager API를 사용하여 사용자 인증 정보 요청을 구성하고 이를
createCredential
에 사용합니다. - Credential Manager API는 공개 키 사용자 인증 정보를 앱과 공유합니다.
- 앱은 삽입된 JavaScript가 응답을 파싱할 수 있도록 공개 키 사용자 인증 정보를 웹페이지로 다시 보냅니다.
- 웹페이지는 공개 키를 백엔드로 전송하고 백엔드에서는 공개 키를 확인하고 저장합니다.
인증(패스키 가져오기)
- 백엔드에서는 인증 JSON을 생성하여 사용자 인증 정보를 가져오고 이를 WebView 클라이언트에서 렌더링되는 웹페이지로 전송합니다.
- 웹페이지에서는
navigator.credentials.get
을 사용합니다. 삽입된 JavaScript를 사용하여 이 메서드를 재정의해 요청을 Android 앱으로 리디렉션합니다. - 앱은
getCredential
호출을 통해 Credential Manager API를 사용하여 사용자 인증 정보를 가져옵니다. - Credential Manager API는 앱에 사용자 인증 정보를 반환합니다.
- 앱은 삽입된 JavaScript가 응답을 파싱할 수 있도록 비공개 키의 디지털 서명을 가져와 웹페이지로 보냅니다.
- 그러면 웹페이지에서는 공개 키로 디지털 서명을 확인하는 서버로 이를 전송합니다.
비밀번호 또는 제휴 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();
}
}
}
}
handleCreateFlow
및 handleGetFlow
는 GitHub의 예시를 참고하세요.
응답 처리
네이티브 앱에서 웹페이지로 전송되는 응답을 처리하려면 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
를 설정합니다. WebViewClient
는 WebView
에 삽입된 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 앱에 통합하면 사용자 인증 정보를 효과적으로 관리하면서 사용자에게 패스키가 지원되는 안전하고 원활한 로그인 환경을 제공할 수 있습니다.