Skip to content

Commit e37face

Browse files
committed
Add files for CredMan integration with WebView
Change-Id: I756e215e788c7712551a57b86facf168b06b7fe3
1 parent 4c63640 commit e37face

File tree

2 files changed

+412
-0
lines changed

2 files changed

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

0 commit comments

Comments
 (0)