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