Skip to content

Commit 225706b

Browse files
author
Niharika Arora
committed
2 parents c602aa0 + e733334 commit 225706b

File tree

2 files changed

+411
-0
lines changed

2 files changed

+411
-0
lines changed
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
package com.leecam.credmanwebtest.webhandler
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+
21+
/**
22+
This web listener looks for the 'postMessage()' call on the javascript web code, and when it
23+
receives it, it will handle it in the manner dictated in this local codebase. This allows for
24+
javascript on the web to interact with the local setup on device that contains more complex logic.
25+
26+
The embedded javascript can be found in CredentialManagerWebView/javascript/encode.js.
27+
It can be modified depending on the use case. If you wish to minify, please use the following command
28+
to call the toptal minifier API.
29+
```
30+
cat encode.js | grep -v '^let __webauthn_interface__;$' | \
31+
curl -X POST --data-urlencode input@- \
32+
https://www.toptal.com/developers/javascript-minifier/api/raw | tr '"' "'" | pbcopy
33+
```
34+
pbpaste should output the proper minimized code. In linux, you may have to alias as follows:
35+
```
36+
alias pbcopy='xclip -selection clipboard'
37+
alias pbpaste='xclip -selection clipboard -o'
38+
```
39+
in your bashrc.
40+
*/
41+
class PasskeyWebListener(
42+
private val activity: Activity,
43+
private val coroutineScope: CoroutineScope,
44+
private val credentialManagerHandler: CredentialManagerHandler
45+
) : WebViewCompat.WebMessageListener {
46+
47+
/** havePendingRequest is true if there is an outstanding WebAuthn request. There is only ever
48+
one request outstanding at a time.*/
49+
private var havePendingRequest = false
50+
51+
/** pendingRequestIsDoomed is true if the WebView has navigated since starting a request. The
52+
fido module cannot be cancelled, but the response will never be delivered in this case.*/
53+
private var pendingRequestIsDoomed = false
54+
55+
/** replyChannel is the port that the page is listening for a response on. It
56+
is valid if `havePendingRequest` is true.*/
57+
private var replyChannel: ReplyChannel? = null
58+
59+
/** Called by the page when it wants to do a WebAuthn `get` or 'post' request. */
60+
@UiThread
61+
override fun onPostMessage(
62+
view: WebView,
63+
message: WebMessageCompat,
64+
sourceOrigin: Uri,
65+
isMainFrame: Boolean,
66+
replyProxy: JavaScriptReplyProxy,
67+
) {
68+
Log.i(TAG, "In Post Message : $message source: $sourceOrigin");
69+
val messageData = message.data ?: return
70+
onRequest(messageData, sourceOrigin, isMainFrame, JavaScriptReplyChannel(replyProxy))
71+
}
72+
73+
private fun onRequest(
74+
msg: String,
75+
sourceOrigin: Uri,
76+
isMainFrame: Boolean,
77+
reply: ReplyChannel,
78+
) {
79+
msg.let {
80+
val jsonObj = JSONObject(msg);
81+
val type = jsonObj.getString(TYPE_KEY)
82+
val message = jsonObj.getString(REQUEST_KEY)
83+
84+
if (havePendingRequest) {
85+
postErrorMessage(reply, "request already in progress", type)
86+
return
87+
}
88+
replyChannel = reply
89+
if (!isMainFrame) {
90+
reportFailure("requests from subframes are not supported", type)
91+
return
92+
}
93+
94+
val originScheme = sourceOrigin.scheme
95+
if (originScheme == null || originScheme.lowercase() != "https") {
96+
reportFailure("WebAuthn not permitted for current URL", type)
97+
return
98+
}
99+
100+
// Verify that origin belongs to your website,
101+
// it's because the unknown origin may gain credential info.
102+
if (isUnknownOrigin(originScheme)) {
103+
return
104+
}
105+
106+
havePendingRequest = true
107+
pendingRequestIsDoomed = false
108+
109+
// Let’s use a temporary “replyCurrent” variable to send the data back, while resetting
110+
// the main “replyChannel” variable to null so it’s ready for the next request.
111+
val replyCurrent = replyChannel
112+
if (replyCurrent == null) {
113+
Log.i(TAG, "reply channel was null, cannot continue")
114+
return;
115+
}
116+
117+
when (type) {
118+
CREATE_UNIQUE_KEY ->
119+
this.coroutineScope.launch {
120+
handleCreateFlow(credentialManagerHandler, message, replyCurrent)
121+
}
122+
GET_UNIQUE_KEY -> this.coroutineScope.launch {
123+
handleGetFlow(credentialManagerHandler, message, replyCurrent)
124+
}
125+
else -> Log.i(TAG, "Incorrect request json")
126+
}
127+
}
128+
}
129+
130+
// Handles the get flow in a less error-prone way
131+
private suspend fun handleGetFlow(
132+
credentialManagerHandler: CredentialManagerHandler,
133+
message: String,
134+
reply: ReplyChannel,
135+
) {
136+
try {
137+
havePendingRequest = false
138+
pendingRequestIsDoomed = false
139+
val r = credentialManagerHandler.getPasskey(message)
140+
val successArray = ArrayList<Any>();
141+
successArray.add("success");
142+
successArray.add(JSONObject(
143+
(r.credential as PublicKeyCredential).authenticationResponseJson))
144+
successArray.add(GET_UNIQUE_KEY);
145+
reply.send(JSONArray(successArray).toString())
146+
replyChannel = null // setting initial replyChannel for next request given temp 'reply'
147+
} catch (e: GetCredentialException) {
148+
reportFailure("Error: ${e.errorMessage} w type: ${e.type} w obj: $e", GET_UNIQUE_KEY)
149+
} catch (t: Throwable) {
150+
reportFailure("Error: ${t.message}", GET_UNIQUE_KEY)
151+
}
152+
}
153+
154+
// handles the create flow in a less error prone way
155+
private suspend fun handleCreateFlow(
156+
credentialManagerHandler: CredentialManagerHandler,
157+
message: String,
158+
reply: ReplyChannel,
159+
) {
160+
try {
161+
havePendingRequest = false
162+
pendingRequestIsDoomed = false
163+
val response = credentialManagerHandler.createPasskey(message)
164+
val successArray = ArrayList<Any>();
165+
successArray.add("success");
166+
successArray.add(JSONObject(response.registrationResponseJson));
167+
successArray.add(CREATE_UNIQUE_KEY);
168+
reply.send(JSONArray(successArray).toString())
169+
replyChannel = null // setting initial replyChannel for next request given temp 'reply'
170+
} catch (e: CreateCredentialException) {
171+
reportFailure("Error: ${e.errorMessage} w type: ${e.type} w obj: $e",
172+
CREATE_UNIQUE_KEY)
173+
} catch (t: Throwable) {
174+
reportFailure("Error: ${t.message}", CREATE_UNIQUE_KEY)
175+
}
176+
}
177+
178+
/** Invalidates any current request. */
179+
fun onPageStarted() {
180+
if (havePendingRequest) {
181+
pendingRequestIsDoomed = true
182+
}
183+
}
184+
185+
/** Sends an error result to the page. */
186+
private fun reportFailure(message: String, type: String) {
187+
havePendingRequest = false
188+
pendingRequestIsDoomed = false
189+
val reply: ReplyChannel = replyChannel!! // verifies non null by throwing NPE
190+
replyChannel = null
191+
postErrorMessage(reply, message, type)
192+
}
193+
194+
private fun postErrorMessage(reply: ReplyChannel, errorMessage: String, type: String) {
195+
Log.i(TAG, "Sending error message back to the page via replyChannel $errorMessage");
196+
val array: MutableList<Any?> = ArrayList()
197+
array.add("error")
198+
array.add(errorMessage)
199+
array.add(type)
200+
reply.send(JSONArray(array).toString())
201+
var toastMsg = errorMessage
202+
Toast.makeText(this.activity.applicationContext, toastMsg, Toast.LENGTH_SHORT).show()
203+
}
204+
205+
private class JavaScriptReplyChannel(private val reply: JavaScriptReplyProxy) :
206+
ReplyChannel {
207+
override fun send(message: String?) {
208+
try {
209+
reply.postMessage(message!!)
210+
}catch (t: Throwable) {
211+
Log.i(TAG, "Reply failure due to: " + t.message);
212+
}
213+
}
214+
}
215+
216+
/** ReplyChannel is the interface over which replies to the embedded site are sent. This allows
217+
for testing because AndroidX bans mocking its objects.*/
218+
interface ReplyChannel {
219+
fun send(message: String?)
220+
}
221+
222+
companion object {
223+
/** INTERFACE_NAME is the name of the MessagePort that must be injected into pages. */
224+
const val INTERFACE_NAME = "__webauthn_interface__"
225+
226+
const val CREATE_UNIQUE_KEY = "create"
227+
const val GET_UNIQUE_KEY = "get"
228+
const val TYPE_KEY = "type"
229+
const val REQUEST_KEY = "request"
230+
231+
/** INJECTED_VAL is the minified version of the JavaScript code described at this class
232+
* heading. The non minified form is found at credmanweb/javascript/encode.js.*/
233+
const val INJECTED_VAL = """
234+
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)};
235+
"""
236+
const val TAG = "PasskeyWebListener"
237+
}
238+
239+
}

0 commit comments

Comments
 (0)