เอกสารนี้อธิบายวิธีผสานรวม Credential Manager API กับแอป Android ที่ใช้ WebView
ภาพรวม
ก่อนที่จะเจาะลึกกระบวนการผสานรวม คุณควรทำความเข้าใจ ขั้นตอนการสื่อสารระหว่างโค้ด Android เนทีฟ คอมโพเนนต์เว็บที่แสดงผล ภายใน WebView ที่จัดการการตรวจสอบสิทธิ์ของแอป และแบ็กเอนด์ ขั้นตอน ประกอบด้วยการลงทะเบียน (การสร้างข้อมูลเข้าสู่ระบบ) และการตรวจสอบสิทธิ์ (การขอรับข้อมูลเข้าสู่ระบบที่มีอยู่)
การลงทะเบียน (สร้างพาสคีย์)
- แบ็กเอนด์จะสร้าง JSON การลงทะเบียนเริ่มต้นและส่ง ไปยังหน้าเว็บที่แสดงภายใน WebView
- หน้าเว็บใช้
navigator.credentials.create()
เพื่อ ลงทะเบียนข้อมูลเข้าสู่ระบบใหม่ คุณจะใช้ JavaScript ที่แทรกเพื่อลบล้าง เมธอดนี้ในขั้นตอนต่อๆ ไปเพื่อส่งคำขอไปยังแอป Android - แอป Android ใช้ Credential Manager API เพื่อสร้างคำขอข้อมูลเข้าสู่ระบบและใช้เพื่อ
createCredential
- API ของเครื่องมือจัดการข้อมูลเข้าสู่ระบบจะแชร์ข้อมูลเข้าสู่ระบบคีย์สาธารณะกับแอป
- แอปจะส่งข้อมูลเข้าสู่ระบบคีย์สาธารณะกลับไปยังหน้าเว็บเพื่อให้ JavaScript ที่แทรกไว้สามารถแยกวิเคราะห์การตอบกลับได้
- หน้าเว็บจะส่งคีย์สาธารณะไปยังแบ็กเอนด์ ซึ่งจะตรวจสอบและบันทึก คีย์สาธารณะ

การตรวจสอบสิทธิ์ (รับพาสคีย์)
- แบ็กเอนด์จะสร้าง JSON การตรวจสอบสิทธิ์เพื่อรับ ข้อมูลเข้าสู่ระบบและส่งไปยังหน้าเว็บที่แสดงในไคลเอ็นต์ WebView
- หน้าเว็บใช้
navigator.credentials.get
ใช้ JavaScript ที่แทรกเพื่อลบล้างเมธอดนี้เพื่อเปลี่ยนเส้นทางคำขอไปยัง แอป Android - แอปจะดึงข้อมูลเข้าสู่ระบบโดยใช้ Credential Manager API ด้วยการเรียก
getCredential
- API ของเครื่องมือจัดการข้อมูลเข้าสู่ระบบจะส่งข้อมูลเข้าสู่ระบบไปยังแอป
- แอปจะรับลายเซ็นดิจิทัลของคีย์ส่วนตัวและส่งไปยัง หน้าเว็บเพื่อให้ JavaScript ที่แทรกสามารถแยกวิเคราะห์การตอบกลับได้
- จากนั้นหน้าเว็บจะส่งข้อมูลไปยังเซิร์ฟเวอร์ที่ตรวจสอบลายเซ็นดิจิทัล ด้วยคีย์สาธารณะ

คุณสามารถใช้ขั้นตอนเดียวกันนี้กับรหัสผ่านหรือระบบข้อมูลประจำตัวแบบรวมศูนย์ได้
สิ่งที่ต้องมีก่อน
หากต้องการใช้ Credential Manager API ให้ทำตามขั้นตอนที่ระบุไว้ในส่วนข้อกำหนดเบื้องต้นของคู่มือ Credential Manager และตรวจสอบว่าคุณได้ทำสิ่งต่อไปนี้
การสื่อสาร JavaScript
หากต้องการอนุญาตให้ JavaScript ใน WebView และโค้ด Android ดั้งเดิมสื่อสารกัน คุณต้องส่งข้อความและจัดการคำขอระหว่าง 2 สภาพแวดล้อม โดย ทำได้ด้วยการแทรกโค้ด JavaScript ที่กำหนดเองลงใน WebView ซึ่งจะช่วยให้คุณแก้ไข ลักษณะการทำงานของเนื้อหาเว็บและโต้ตอบกับโค้ด Android ที่มาพร้อมเครื่องได้
การแทรก JavaScript
โค้ด JavaScript ต่อไปนี้จะสร้างการสื่อสารระหว่าง WebView กับแอป Android โดยจะลบล้างเมธอด navigator.credentials.create()
และ navigator.credentials.get()
ที่ใช้โดย WebAuthn API สำหรับขั้นตอนการลงทะเบียนและการตรวจสอบสิทธิ์ที่อธิบายไว้ก่อนหน้านี้
ใช้โค้ด JavaScript เวอร์ชันย่อนี้ในแอปพลิเคชัน
สร้าง Listener สำหรับพาสคีย์
สร้างPasskeyWebListener
คลาสที่จัดการการสื่อสาร
ด้วย JavaScript คลาสนี้ควรสืบทอดมาจาก
WebViewCompat.WebMessageListener
คลาสนี้รับข้อความจาก JavaScript และดำเนินการที่จำเป็นในแอป Android
ส่วนต่อไปนี้จะอธิบายโครงสร้างของคลาส PasskeyWebListener
รวมถึงการจัดการคำขอและการตอบกลับ
จัดการคำขอการตรวจสอบสิทธิ์
หากต้องการจัดการคำขอสำหรับการดำเนินการ WebAuthn navigator.credentials.create()
หรือ
navigator.credentials.get()
ระบบจะเรียกใช้เมธอด onPostMessage
ของคลาส
PasskeyWebListener
เมื่อโค้ด JavaScript ส่งข้อความไปยัง
แอป Android
// The class talking to Javascript should inherit:
class PasskeyWebListener(
private val activity: Activity,
private val coroutineScope: CoroutineScope,
private val credentialManagerHandler: CredentialManagerHandler
) : WebViewCompat.WebMessageListener {
/** 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 {
/** INTERFACE_NAME is the name of the MessagePort that must be injected into pages. */
const val INTERFACE_NAME = "__webauthn_interface__"
const val TYPE_KEY = "type"
const val REQUEST_KEY = "request"
const val CREATE_UNIQUE_KEY = "create"
const val GET_UNIQUE_KEY = "get"
/** INJECTED_VAL is the minified version of the JavaScript code described at this class
* heading. The non minified form is found at credmanweb/javascript/encode.js.*/
const val INJECTED_VAL = """
var __webauthn_interface__,__webauthn_hooks__;!function(e){console.log("In the hook."),__webauthn_interface__.addEventListener("message",function e(n){var r=JSON.parse(n.data),t=r[2];"get"===t?o(r):"create"===t?u(r):console.log("Incorrect response format for reply")});var n=null,r=null,t=null,a=null;function o(e){if(null!==n&&null!==t){if("success"!=e[0]){var r=t;n=null,t=null,r(new DOMException(e[1],"NotAllowedError"));return}var a=i(e[1]),o=n;n=null,t=null,o(a)}}function l(e){var n=e.length%4;return Uint8Array.from(atob(e.replace(/-/g,"+").replace(/_/g,"/").padEnd(e.length+(0===n?0:4-n),"=")),function(e){return e.charCodeAt(0)}).buffer}function s(e){return btoa(Array.from(new Uint8Array(e),function(e){return String.fromCharCode(e)}).join("")).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+${'$'}/,"")}function u(e){if(null===r||null===a){console.log("Here: "+r+" and reject: "+a);return}if(console.log("Output back: "+e),"success"!=e[0]){var n=a;r=null,a=null,n(new DOMException(e[1],"NotAllowedError"));return}var t=i(e[1]),o=r;r=null,a=null,o(t)}function i(e){return console.log("Here is the response from credential manager: "+e),e.rawId=l(e.rawId),e.response.clientDataJSON=l(e.response.clientDataJSON),e.response.hasOwnProperty("attestationObject")&&(e.response.attestationObject=l(e.response.attestationObject)),e.response.hasOwnProperty("authenticatorData")&&(e.response.authenticatorData=l(e.response.authenticatorData)),e.response.hasOwnProperty("signature")&&(e.response.signature=l(e.response.signature)),e.response.hasOwnProperty("userHandle")&&(e.response.userHandle=l(e.response.userHandle)),e.getClientExtensionResults=function e(){return{}},e}e.create=function n(t){if(!("publicKey"in t))return e.originalCreateFunction(t);var o=new Promise(function(e,n){r=e,a=n}),l=t.publicKey;if(l.hasOwnProperty("challenge")){var u=s(l.challenge);l.challenge=u}if(l.hasOwnProperty("user")&&l.user.hasOwnProperty("id")){var i=s(l.user.id);l.user.id=i}var c=JSON.stringify({type:"create",request:l});return __webauthn_interface__.postMessage(c),o},e.get=function r(a){if(!("publicKey"in a))return e.originalGetFunction(a);var o=new Promise(function(e,r){n=e,t=r}),l=a.publicKey;if(l.hasOwnProperty("challenge")){var u=s(l.challenge);l.challenge=u}var i=JSON.stringify({type:"get",request:l});return __webauthn_interface__.postMessage(i),o},e.onReplyGet=o,e.CM_base64url_decode=l,e.CM_base64url_encode=s,e.onReplyCreate=u}(__webauthn_hooks__||(__webauthn_hooks__={})),__webauthn_hooks__.originalGetFunction=navigator.credentials.get,__webauthn_hooks__.originalCreateFunction=navigator.credentials.create,navigator.credentials.get=__webauthn_hooks__.get,navigator.credentials.create=__webauthn_hooks__.create,window.PublicKeyCredential=function(){},window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable=function(){return Promise.resolve(!1)};
"""
}
สำหรับ handleCreateFlow
และ handleGetFlow
โปรดดูตัวอย่างใน
GitHub
จัดการคำตอบ
หากต้องการจัดการการตอบกลับที่ส่งจากแอปเนทีฟไปยังหน้าเว็บ ให้เพิ่ม
JavaScriptReplyProxy
ภายใน JavaScriptReplyChannel
// 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?)
}
อย่าลืมตรวจหาข้อผิดพลาดจากแอปเนทีฟและส่งกลับไปยังฝั่ง JavaScript
ผสานรวมกับ WebView
ส่วนนี้จะอธิบายวิธีตั้งค่าการผสานรวม WebView
เริ่มต้น WebView
ในกิจกรรมของแอป Android ให้เริ่มต้น WebView
และตั้งค่า WebViewClient
ที่เกี่ยวข้อง WebViewClient
จัดการการสื่อสารกับโค้ด JavaScript ที่แทรกลงใน WebView
ตั้งค่า WebView และเรียกใช้ Credential Manager โดยทำดังนี้
val credentialManagerHandler = CredentialManagerHandler(this)
setContent {
val coroutineScope = rememberCoroutineScope()
AndroidView(factory = {
WebView(it).apply {
settings.javaScriptEnabled = true
// Test URL:
val url = "https://passkeys-codelab.glitch.me/"
val listenerSupported = WebViewFeature.isFeatureSupported(
WebViewFeature.WEB_MESSAGE_LISTENER
)
if (listenerSupported) {
// Inject local JavaScript that calls Credential Manager.
hookWebAuthnWithListener(
this, this@WebViewMainActivity,
coroutineScope, credentialManagerHandler
)
} else {
// Fallback routine for unsupported API levels.
}
loadUrl(url)
}
}
)
}
สร้างออบเจ็กต์ไคลเอ็นต์ WebView ใหม่และแทรก JavaScript ลงในหน้าเว็บ
val passkeyWebListener = PasskeyWebListener(activity, coroutineScope, credentialManagerHandler)
val webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
webView.evaluateJavascript(PasskeyWebListener.INJECTED_VAL, null)
}
}
webView.webViewClient = webViewClient
ตั้งค่าเครื่องรับฟังข้อความเว็บ
หากต้องการอนุญาตให้โพสต์ข้อความระหว่าง JavaScript กับแอป Android ให้ตั้งค่า
เครื่องมือฟังข้อความเว็บด้วยเมธอด WebViewCompat.addWebMessageListener
val rules = setOf("*")
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 กับโค้ด JavaScript ใน WebView โดยจะจัดการการส่งข้อความ การสื่อสาร และการดำเนินการที่จำเป็น - ปรับตัวอย่างโค้ดที่ให้ไว้ให้เหมาะกับโครงสร้าง การตั้งชื่อ แบบแผน และข้อกำหนดเฉพาะใดๆ ที่คุณอาจมี
- ตรวจหาข้อผิดพลาดในฝั่งแอปเนทีฟและส่งกลับไปยังฝั่ง JavaScript
การทำตามคำแนะนำนี้และการผสานรวม Credential Manager API เข้ากับ แอป Android ที่ใช้ WebView จะช่วยให้คุณมอบประสบการณ์การเข้าสู่ระบบที่ปลอดภัยและราบรื่น ซึ่งเปิดใช้พาสคีย์ให้แก่ผู้ใช้ พร้อมทั้งจัดการข้อมูลเข้าสู่ระบบของผู้ใช้ได้อย่างมีประสิทธิภาพ