במסמך הזה מוסבר איך לשלב את Credential Manager API באפליקציית Android שמשתמשת ב-WebView.
סקירה כללית
לפני שמתחילים בתהליך השילוב, חשוב להבין את זרימת התקשורת בין קוד Android מקורי, רכיב אינטרנט שעבר עיבוד ב-WebView שמנהל את האימות של האפליקציה, ובין קצה העורף. התהליך כולל הרשמה (יצירת פרטי כניסה) ואימות (קבלת פרטי כניסה קיימים).
הרשמה (יצירת מפתח גישה)
- הקצה העורפי יוצר קובץ JSON של רישום ושולח אותו לדף האינטרנט שמעובד ב-WebView.
- דף האינטרנט משתמש ב-
navigator.credentials.create()
כדי לרשום פרטי כניסה חדשים. תשתמשו ב-JavaScript המוזרק כדי לבטל את השיטה הזו בשלב מאוחר יותר, כדי לשלוח את הבקשה לאפליקציית Android. - אפליקציית Android משתמשת ב-Credential Manager API כדי ליצור את הבקשה לפרטי הכניסה ולהשתמש בה כדי
createCredential
. - ה-API של Credential Manager משתף את פרטי הכניסה של המפתח הציבורי עם האפליקציה.
- האפליקציה שולחת את פרטי הכניסה של המפתח הציבורי בחזרה לדף האינטרנט, כדי שקוד ה-JavaScript המוזרק יוכל לנתח את התגובות.
- דף האינטרנט שולח את המפתח הציבורי אל ה-Backend, שמאמת ושומר את המפתח הציבורי.

אימות (קבלת מפתח גישה)
- הקצה העורפי יוצר אימות JSON כדי לקבל את פרטי הכניסה ושולח אותו לדף האינטרנט שעובר עיבוד בלקוח WebView.
- דף האינטרנט משתמש ב-
navigator.credentials.get
. משתמשים ב-JavaScript המוזרק כדי לבטל את השיטה הזו ולהפנות את הבקשה לאפליקציית Android. - האפליקציה מאחזרת את פרטי הכניסה באמצעות Credential Manager API על ידי קריאה ל-
getCredential
. - ממשק Credential Manager API מחזיר את פרטי הכניסה לאפליקציה.
- האפליקציה מקבלת את החתימה הדיגיטלית של המפתח הפרטי ושולחת אותה לדף האינטרנט, כדי שקוד ה-JavaScript המוזרק יוכל לנתח את התשובות.
- לאחר מכן, דף האינטרנט שולח אותו לשרת שמאמת את החתימה הדיגיטלית באמצעות המפתח הציבורי.

אפשר להשתמש באותו תהליך גם לסיסמאות או למערכות זהויות מאוחדות.
דרישות מוקדמות
כדי להשתמש ב-Credential Manager API, צריך לבצע את השלבים שמפורטים בקטע דרישות מוקדמות במדריך Credential Manager, ולוודא שאתם מבצעים את הפעולות הבאות:
תקשורת JavaScript
כדי לאפשר ל-JavaScript ב-WebView ולקוד מקורי של Android לתקשר ביניהם, צריך לשלוח הודעות ולטפל בבקשות בין שני הסביבות. כדי לעשות את זה, צריך להחדיר קוד JavaScript מותאם אישית ל-WebView. כך אפשר לשנות את ההתנהגות של תוכן אינטרנט ולבצע אינטראקציה עם קוד מקורי של Android.
החדרה של JavaScript
קוד ה-JavaScript הבא יוצר תקשורת בין WebView לבין אפליקציית Android. הוא מבטל את השימוש בשיטות navigator.credentials.create()
ו-navigator.credentials.get()
שמשמשות את WebAuthn API בתהליכי הרישום והאימות שמתוארים למעלה.
משתמשים בגרסה הממוזערת של קוד ה-JavaScript הזה באפליקציה.
יצירת מאזין למפתחות גישה
מגדירים מחלקת 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 וקוראים למנהל פרטי הכניסה:
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, דף האינטרנט והקצה העורפי.
פורסים את הפתרון המשולב בסביבת הייצור, ומוודאים שהקצה העורפי יכול לטפל בבקשות נכנסות של הרשמה ואימות. קוד ה-Backend צריך ליצור JSON ראשוני לתהליכי רישום (יצירה) ואימות (קבלת נתונים). הוא צריך גם לטפל באימות של התשובות שמתקבלות מדף האינטרנט.
מוודאים שההטמעה תואמת להמלצות בנושא חוויית משתמש.
הערות חשובות
- משתמשים בקוד JavaScript שסופק כדי לטפל בפעולות
navigator.credentials.create()
ו-navigator.credentials.get()
. - המחלקות
PasskeyWebListener
הן הגשר בין אפליקציית Android לבין קוד JavaScript ב-WebView. הוא מטפל בהעברת הודעות, בתקשורת ובביצוע של פעולות נדרשות. - אפשר להתאים את קטעי הקוד שמופיעים כאן למבנה הפרויקט, למוסכמות השמות ולדרישות הספציפיות שלכם.
- לזהות שגיאות בצד האפליקציה המקורית ולשלוח אותן בחזרה לצד JavaScript.
אם תפעלו לפי ההוראות במדריך הזה ותשלבו את Credential Manager API באפליקציית Android שלכם שמשתמשת ב-WebView, תוכלו לספק למשתמשים חוויה מאובטחת וחלקה של כניסה באמצעות מפתחות גישה, ולנהל את פרטי הכניסה שלהם בצורה יעילה.