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+ }
0 commit comments