Skip to content

Commit 6f36723

Browse files
authored
Add WebView snippets (#503)
1 parent 39f2817 commit 6f36723

File tree

5 files changed

+368
-0
lines changed

5 files changed

+368
-0
lines changed

gradle/libs.versions.toml

+2
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ wearComposeMaterial = "1.4.1"
6868
wearToolingPreview = "1.0.0"
6969
activityKtx = "1.10.0"
7070
okHttp = "4.12.0"
71+
webkit = "1.13.0"
7172

7273
[libraries]
7374
accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" }
@@ -171,6 +172,7 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa
171172
play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "playServicesWearable" }
172173
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" }
173174
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okHttp" }
175+
androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
174176

175177
[plugins]
176178
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }

identity/credentialmanager/build.gradle.kts

+1
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ dependencies {
6969
// [END android_identity_siwg_gradle_dependencies]
7070
implementation(libs.okhttp)
7171
implementation(libs.kotlin.coroutines.okhttp)
72+
implementation(libs.androidx.webkit)
7273
debugImplementation(libs.androidx.compose.ui.tooling)
7374
debugImplementation(libs.androidx.compose.ui.test.manifest)
7475
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.example.identity.credentialmanager
2+
3+
import android.app.Activity
4+
import android.util.Log
5+
import androidx.credentials.CreatePublicKeyCredentialRequest
6+
import androidx.credentials.CreatePublicKeyCredentialResponse
7+
import androidx.credentials.CredentialManager
8+
import androidx.credentials.GetCredentialRequest
9+
import androidx.credentials.GetCredentialResponse
10+
import androidx.credentials.GetPublicKeyCredentialOption
11+
import androidx.credentials.exceptions.CreateCredentialException
12+
import androidx.credentials.exceptions.GetCredentialException
13+
14+
// This class is mostly copied from https://github.com/android/identity-samples/blob/main/WebView/CredentialManagerWebView/CredentialManagerHandler.kt.
15+
class CredentialManagerHandler(private val activity: Activity) {
16+
private val mCredMan = CredentialManager.create(activity.applicationContext)
17+
private val TAG = "CredentialManagerHandler"
18+
/**
19+
* Encapsulates the create passkey API for credential manager in a less error-prone manner.
20+
*
21+
* @param request a create public key credential request JSON required by [CreatePublicKeyCredentialRequest].
22+
* @return [CreatePublicKeyCredentialResponse] containing the result of the credential creation.
23+
*/
24+
suspend fun createPasskey(request: String): CreatePublicKeyCredentialResponse {
25+
val createRequest = CreatePublicKeyCredentialRequest(request)
26+
try {
27+
return mCredMan.createCredential(activity, createRequest) as CreatePublicKeyCredentialResponse
28+
} catch (e: CreateCredentialException) {
29+
// For error handling use guidance from https://developer.android.com/training/sign-in/passkeys
30+
Log.i(TAG, "Error creating credential: ErrMessage: ${e.errorMessage}, ErrType: ${e.type}")
31+
throw e
32+
}
33+
}
34+
35+
/**
36+
* Encapsulates the get passkey API for credential manager in a less error-prone manner.
37+
*
38+
* @param request a get public key credential request JSON required by [GetCredentialRequest].
39+
* @return [GetCredentialResponse] containing the result of the credential retrieval.
40+
*/
41+
suspend fun getPasskey(request: String): GetCredentialResponse {
42+
val getRequest = GetCredentialRequest(listOf(GetPublicKeyCredentialOption(request, null)))
43+
try {
44+
return mCredMan.getCredential(activity, getRequest)
45+
} catch (e: GetCredentialException) {
46+
// For error handling use guidance from https://developer.android.com/training/sign-in/passkeys
47+
Log.i(TAG, "Error retrieving credential: ${e.message}")
48+
throw e
49+
}
50+
}
51+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
package com.example.identity.credentialmanager
2+
3+
import android.app.Activity
4+
import android.net.Uri
5+
import android.util.Log
6+
import android.webkit.WebView
7+
import android.widget.Toast
8+
import androidx.annotation.UiThread
9+
import androidx.credentials.PublicKeyCredential
10+
import androidx.credentials.exceptions.CreateCredentialException
11+
import androidx.credentials.exceptions.GetCredentialException
12+
import androidx.webkit.JavaScriptReplyProxy
13+
import androidx.webkit.WebMessageCompat
14+
import androidx.webkit.WebViewCompat
15+
import kotlinx.coroutines.CoroutineScope
16+
import kotlinx.coroutines.launch
17+
import org.json.JSONArray
18+
import org.json.JSONObject
19+
20+
// Placeholder for TAG log value.
21+
const val TAG = ""
22+
23+
// This class is mostly copied from https://github.com/android/identity-samples/blob/main/WebView/CredentialManagerWebView/PasskeyWebListener.kt.
24+
25+
// [START android_identity_create_listener_passkeys]
26+
// The class talking to Javascript should inherit:
27+
class PasskeyWebListener(
28+
private val activity: Activity,
29+
private val coroutineScope: CoroutineScope,
30+
private val credentialManagerHandler: CredentialManagerHandler
31+
) : WebViewCompat.WebMessageListener {
32+
/** havePendingRequest is true if there is an outstanding WebAuthn request.
33+
There is only ever one request outstanding at a time. */
34+
private var havePendingRequest = false
35+
36+
/** pendingRequestIsDoomed is true if the WebView has navigated since
37+
starting a request. The FIDO module cannot be canceled, but the response
38+
will never be delivered in this case. */
39+
private var pendingRequestIsDoomed = false
40+
41+
/** replyChannel is the port that the page is listening for a response on.
42+
It is valid if havePendingRequest is true. */
43+
private var replyChannel: ReplyChannel? = null
44+
45+
/**
46+
* Called by the page during a WebAuthn request.
47+
*
48+
* @param view Creates the WebView.
49+
* @param message The message sent from the client using injected JavaScript.
50+
* @param sourceOrigin The origin of the HTTPS request. Should not be null.
51+
* @param isMainFrame Should be set to true. Embedded frames are not
52+
supported.
53+
* @param replyProxy Passed in by JavaScript. Allows replying when wrapped in
54+
the Channel.
55+
* @return The message response.
56+
*/
57+
@UiThread
58+
override fun onPostMessage(
59+
view: WebView,
60+
message: WebMessageCompat,
61+
sourceOrigin: Uri,
62+
isMainFrame: Boolean,
63+
replyProxy: JavaScriptReplyProxy,
64+
) {
65+
val messageData = message.data ?: return
66+
onRequest(
67+
messageData,
68+
sourceOrigin,
69+
isMainFrame,
70+
JavaScriptReplyChannel(replyProxy)
71+
)
72+
}
73+
74+
private fun onRequest(
75+
msg: String,
76+
sourceOrigin: Uri,
77+
isMainFrame: Boolean,
78+
reply: ReplyChannel,
79+
) {
80+
msg?.let {
81+
val jsonObj = JSONObject(msg);
82+
val type = jsonObj.getString(TYPE_KEY)
83+
val message = jsonObj.getString(REQUEST_KEY)
84+
85+
if (havePendingRequest) {
86+
postErrorMessage(reply, "The request already in progress", type)
87+
return
88+
}
89+
90+
replyChannel = reply
91+
if (!isMainFrame) {
92+
reportFailure("Requests from subframes are not supported", type)
93+
return
94+
}
95+
val originScheme = sourceOrigin.scheme
96+
if (originScheme == null || originScheme.lowercase() != "https") {
97+
reportFailure("WebAuthn not permitted for current URL", type)
98+
return
99+
}
100+
101+
// Verify that origin belongs to your website,
102+
// it's because the unknown origin may gain credential info.
103+
// if (isUnknownOrigin(originScheme)) {
104+
// return
105+
// }
106+
107+
havePendingRequest = true
108+
pendingRequestIsDoomed = false
109+
110+
// Use a temporary "replyCurrent" variable to send the data back, while
111+
// resetting the main "replyChannel" variable to null so it’s ready for
112+
// the next request.
113+
val replyCurrent = replyChannel
114+
if (replyCurrent == null) {
115+
Log.i(TAG, "The reply channel was null, cannot continue")
116+
return;
117+
}
118+
119+
when (type) {
120+
CREATE_UNIQUE_KEY ->
121+
this.coroutineScope.launch {
122+
handleCreateFlow(credentialManagerHandler, message, replyCurrent)
123+
}
124+
125+
GET_UNIQUE_KEY -> this.coroutineScope.launch {
126+
handleGetFlow(credentialManagerHandler, message, replyCurrent)
127+
}
128+
129+
else -> Log.i(TAG, "Incorrect request json")
130+
}
131+
}
132+
}
133+
134+
private suspend fun handleCreateFlow(
135+
credentialManagerHandler: CredentialManagerHandler,
136+
message: String,
137+
reply: ReplyChannel,
138+
) {
139+
try {
140+
havePendingRequest = false
141+
pendingRequestIsDoomed = false
142+
val response = credentialManagerHandler.createPasskey(message)
143+
val successArray = ArrayList<Any>();
144+
successArray.add("success");
145+
successArray.add(JSONObject(response.registrationResponseJson));
146+
successArray.add(CREATE_UNIQUE_KEY);
147+
reply.send(JSONArray(successArray).toString())
148+
replyChannel = null // setting initial replyChannel for the next request
149+
} catch (e: CreateCredentialException) {
150+
reportFailure(
151+
"Error: ${e.errorMessage} w type: ${e.type} w obj: $e",
152+
CREATE_UNIQUE_KEY
153+
)
154+
} catch (t: Throwable) {
155+
reportFailure("Error: ${t.message}", CREATE_UNIQUE_KEY)
156+
}
157+
}
158+
159+
companion object {
160+
/** INTERFACE_NAME is the name of the MessagePort that must be injected into pages. */
161+
const val INTERFACE_NAME = "__webauthn_interface__"
162+
const val TYPE_KEY = "type"
163+
const val REQUEST_KEY = "request"
164+
const val CREATE_UNIQUE_KEY = "create"
165+
const val GET_UNIQUE_KEY = "get"
166+
/** INJECTED_VAL is the minified version of the JavaScript code described at this class
167+
* heading. The non minified form is found at credmanweb/javascript/encode.js.*/
168+
const val INJECTED_VAL = """
169+
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)};
170+
"""
171+
}
172+
// [END android_identity_create_listener_passkeys]
173+
174+
// Handles the get flow in a less error-prone way
175+
private suspend fun handleGetFlow(
176+
credentialManagerHandler: CredentialManagerHandler,
177+
message: String,
178+
reply: ReplyChannel,
179+
) {
180+
try {
181+
havePendingRequest = false
182+
pendingRequestIsDoomed = false
183+
val r = credentialManagerHandler.getPasskey(message)
184+
val successArray = ArrayList<Any>();
185+
successArray.add("success");
186+
successArray.add(JSONObject(
187+
(r.credential as PublicKeyCredential).authenticationResponseJson))
188+
successArray.add(GET_UNIQUE_KEY);
189+
reply.send(JSONArray(successArray).toString())
190+
replyChannel = null // setting initial replyChannel for next request given temp 'reply'
191+
} catch (e: GetCredentialException) {
192+
reportFailure("Error: ${e.errorMessage} w type: ${e.type} w obj: $e", GET_UNIQUE_KEY)
193+
} catch (t: Throwable) {
194+
reportFailure("Error: ${t.message}", GET_UNIQUE_KEY)
195+
}
196+
}
197+
198+
/** Sends an error result to the page. */
199+
private fun reportFailure(message: String, type: String) {
200+
havePendingRequest = false
201+
pendingRequestIsDoomed = false
202+
val reply: ReplyChannel = replyChannel!! // verifies non null by throwing NPE
203+
replyChannel = null
204+
postErrorMessage(reply, message, type)
205+
}
206+
207+
private fun postErrorMessage(reply: ReplyChannel, errorMessage: String, type: String) {
208+
Log.i(TAG, "Sending error message back to the page via replyChannel $errorMessage");
209+
val array: MutableList<Any?> = ArrayList()
210+
array.add("error")
211+
array.add(errorMessage)
212+
array.add(type)
213+
reply.send(JSONArray(array).toString())
214+
var toastMsg = errorMessage
215+
Toast.makeText(this.activity.applicationContext, toastMsg, Toast.LENGTH_SHORT).show()
216+
}
217+
218+
// [START android_identity_javascript_reply_channel]
219+
// The setup for the reply channel allows communication with JavaScript.
220+
private class JavaScriptReplyChannel(private val reply: JavaScriptReplyProxy) :
221+
ReplyChannel {
222+
override fun send(message: String?) {
223+
try {
224+
reply.postMessage(message!!)
225+
} catch (t: Throwable) {
226+
Log.i(TAG, "Reply failure due to: " + t.message);
227+
}
228+
}
229+
}
230+
231+
// ReplyChannel is the interface where replies to the embedded site are
232+
// sent. This allows for testing since AndroidX bans mocking its objects.
233+
interface ReplyChannel {
234+
fun send(message: String?)
235+
}
236+
// [END android_identity_javascript_reply_channel]
237+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package com.example.identity.credentialmanager
2+
3+
import android.graphics.Bitmap
4+
import android.os.Bundle
5+
import android.webkit.WebView
6+
import android.webkit.WebViewClient
7+
import androidx.activity.ComponentActivity
8+
import androidx.activity.compose.setContent
9+
import androidx.compose.runtime.rememberCoroutineScope
10+
import androidx.compose.ui.viewinterop.AndroidView
11+
import androidx.webkit.WebViewCompat
12+
import androidx.webkit.WebViewFeature
13+
import kotlinx.coroutines.CoroutineScope
14+
15+
class WebViewMainActivity : ComponentActivity() {
16+
override fun onCreate(savedInstanceState: Bundle?) {
17+
super.onCreate(savedInstanceState)
18+
19+
// [START android_identity_initialize_the_webview]
20+
val credentialManagerHandler = CredentialManagerHandler(this)
21+
22+
setContent {
23+
val coroutineScope = rememberCoroutineScope()
24+
AndroidView(factory = {
25+
WebView(it).apply {
26+
settings.javaScriptEnabled = true
27+
28+
// Test URL:
29+
val url = "https://credman-web-test.glitch.me/"
30+
val listenerSupported = WebViewFeature.isFeatureSupported(
31+
WebViewFeature.WEB_MESSAGE_LISTENER
32+
)
33+
if (listenerSupported) {
34+
// Inject local JavaScript that calls Credential Manager.
35+
hookWebAuthnWithListener(
36+
this, this@WebViewMainActivity,
37+
coroutineScope, credentialManagerHandler
38+
)
39+
} else {
40+
// Fallback routine for unsupported API levels.
41+
}
42+
loadUrl(url)
43+
}
44+
}
45+
)
46+
}
47+
// [END android_identity_initialize_the_webview]
48+
}
49+
50+
/**
51+
* Connects the local app logic with the web page via injection of javascript through a
52+
* WebListener. Handles ensuring the [PasskeyWebListener] is hooked up to the webView page
53+
* if compatible.
54+
*/
55+
fun hookWebAuthnWithListener(
56+
webView: WebView,
57+
activity: WebViewMainActivity,
58+
coroutineScope: CoroutineScope,
59+
credentialManagerHandler: CredentialManagerHandler
60+
) {
61+
val passkeyWebListener = PasskeyWebListener(activity, coroutineScope, credentialManagerHandler)
62+
val webViewClient = object : WebViewClient() {
63+
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
64+
super.onPageStarted(view, url, favicon)
65+
webView.evaluateJavascript(PasskeyWebListener.INJECTED_VAL, null)
66+
}
67+
}
68+
69+
val rules = setOf("*")
70+
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
71+
WebViewCompat.addWebMessageListener(webView, PasskeyWebListener.INTERFACE_NAME,
72+
rules, passkeyWebListener)
73+
}
74+
75+
webView.webViewClient = webViewClient
76+
}
77+
}

0 commit comments

Comments
 (0)