เอกสารนี้อธิบายวิธีผสานรวม 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 จะช่วยให้คุณมอบประสบการณ์การเข้าสู่ระบบที่ปลอดภัยและราบรื่น ซึ่งเปิดใช้พาสคีย์ให้แก่ผู้ใช้ พร้อมทั้งจัดการข้อมูลเข้าสู่ระบบของผู้ใช้ได้อย่างมีประสิทธิภาพ