diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e1eeb5421..59b8d7f1b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -68,6 +68,7 @@ wearComposeMaterial = "1.4.1" wearToolingPreview = "1.0.0" activityKtx = "1.10.0" okHttp = "4.12.0" +webkit = "1.13.0" [libraries] accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" } @@ -171,6 +172,7 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "playServicesWearable" } androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okHttp" } +androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } diff --git a/identity/credentialmanager/build.gradle.kts b/identity/credentialmanager/build.gradle.kts index 90da586a2..0d1291dbd 100644 --- a/identity/credentialmanager/build.gradle.kts +++ b/identity/credentialmanager/build.gradle.kts @@ -69,6 +69,7 @@ dependencies { // [END android_identity_siwg_gradle_dependencies] implementation(libs.okhttp) implementation(libs.kotlin.coroutines.okhttp) + implementation(libs.androidx.webkit) debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/CredentialManagerHandler.kt b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/CredentialManagerHandler.kt new file mode 100644 index 000000000..36f4ee175 --- /dev/null +++ b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/CredentialManagerHandler.kt @@ -0,0 +1,51 @@ +package com.example.identity.credentialmanager + +import android.app.Activity +import android.util.Log +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.exceptions.CreateCredentialException +import androidx.credentials.exceptions.GetCredentialException + +// This class is mostly copied from https://github.com/android/identity-samples/blob/main/WebView/CredentialManagerWebView/CredentialManagerHandler.kt. +class CredentialManagerHandler(private val activity: Activity) { + private val mCredMan = CredentialManager.create(activity.applicationContext) + private val TAG = "CredentialManagerHandler" + /** + * Encapsulates the create passkey API for credential manager in a less error-prone manner. + * + * @param request a create public key credential request JSON required by [CreatePublicKeyCredentialRequest]. + * @return [CreatePublicKeyCredentialResponse] containing the result of the credential creation. + */ + suspend fun createPasskey(request: String): CreatePublicKeyCredentialResponse { + val createRequest = CreatePublicKeyCredentialRequest(request) + try { + return mCredMan.createCredential(activity, createRequest) as CreatePublicKeyCredentialResponse + } catch (e: CreateCredentialException) { + // For error handling use guidance from https://developer.android.com/training/sign-in/passkeys + Log.i(TAG, "Error creating credential: ErrMessage: ${e.errorMessage}, ErrType: ${e.type}") + throw e + } + } + + /** + * Encapsulates the get passkey API for credential manager in a less error-prone manner. + * + * @param request a get public key credential request JSON required by [GetCredentialRequest]. + * @return [GetCredentialResponse] containing the result of the credential retrieval. + */ + suspend fun getPasskey(request: String): GetCredentialResponse { + val getRequest = GetCredentialRequest(listOf(GetPublicKeyCredentialOption(request, null))) + try { + return mCredMan.getCredential(activity, getRequest) + } catch (e: GetCredentialException) { + // For error handling use guidance from https://developer.android.com/training/sign-in/passkeys + Log.i(TAG, "Error retrieving credential: ${e.message}") + throw e + } + } +} diff --git a/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/PasskeyWebListener.kt b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/PasskeyWebListener.kt new file mode 100644 index 000000000..05119cd96 --- /dev/null +++ b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/PasskeyWebListener.kt @@ -0,0 +1,237 @@ +package com.example.identity.credentialmanager + +import android.app.Activity +import android.net.Uri +import android.util.Log +import android.webkit.WebView +import android.widget.Toast +import androidx.annotation.UiThread +import androidx.credentials.PublicKeyCredential +import androidx.credentials.exceptions.CreateCredentialException +import androidx.credentials.exceptions.GetCredentialException +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebMessageCompat +import androidx.webkit.WebViewCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.json.JSONArray +import org.json.JSONObject + +// Placeholder for TAG log value. +const val TAG = "" + +// This class is mostly copied from https://github.com/android/identity-samples/blob/main/WebView/CredentialManagerWebView/PasskeyWebListener.kt. + +// [START android_identity_create_listener_passkeys] +// The class talking to Javascript should inherit: +class PasskeyWebListener( + private val activity: Activity, + private val coroutineScope: CoroutineScope, + private val credentialManagerHandler: CredentialManagerHandler +) : WebViewCompat.WebMessageListener { + /** havePendingRequest is true if there is an outstanding WebAuthn request. + There is only ever one request outstanding at a time. */ + private var havePendingRequest = false + + /** pendingRequestIsDoomed is true if the WebView has navigated since + starting a request. The FIDO module cannot be canceled, but the response + will never be delivered in this case. */ + private var pendingRequestIsDoomed = false + + /** replyChannel is the port that the page is listening for a response on. + It is valid if havePendingRequest is true. */ + private var replyChannel: ReplyChannel? = null + + /** + * Called by the page during a WebAuthn request. + * + * @param view Creates the WebView. + * @param message The message sent from the client using injected JavaScript. + * @param sourceOrigin The origin of the HTTPS request. Should not be null. + * @param isMainFrame Should be set to true. Embedded frames are not + supported. + * @param replyProxy Passed in by JavaScript. Allows replying when wrapped in + the Channel. + * @return The message response. + */ + @UiThread + override fun onPostMessage( + view: WebView, + message: WebMessageCompat, + sourceOrigin: Uri, + isMainFrame: Boolean, + replyProxy: JavaScriptReplyProxy, + ) { + val messageData = message.data ?: return + onRequest( + messageData, + sourceOrigin, + isMainFrame, + JavaScriptReplyChannel(replyProxy) + ) + } + + private fun onRequest( + msg: String, + sourceOrigin: Uri, + isMainFrame: Boolean, + reply: ReplyChannel, + ) { + msg?.let { + val jsonObj = JSONObject(msg); + val type = jsonObj.getString(TYPE_KEY) + val message = jsonObj.getString(REQUEST_KEY) + + if (havePendingRequest) { + postErrorMessage(reply, "The request already in progress", type) + return + } + + replyChannel = reply + if (!isMainFrame) { + reportFailure("Requests from subframes are not supported", type) + return + } + val originScheme = sourceOrigin.scheme + if (originScheme == null || originScheme.lowercase() != "https") { + reportFailure("WebAuthn not permitted for current URL", type) + return + } + + // Verify that origin belongs to your website, + // it's because the unknown origin may gain credential info. + // if (isUnknownOrigin(originScheme)) { + // return + // } + + havePendingRequest = true + pendingRequestIsDoomed = false + + // Use a temporary "replyCurrent" variable to send the data back, while + // resetting the main "replyChannel" variable to null so it’s ready for + // the next request. + val replyCurrent = replyChannel + if (replyCurrent == null) { + Log.i(TAG, "The reply channel was null, cannot continue") + return; + } + + when (type) { + CREATE_UNIQUE_KEY -> + this.coroutineScope.launch { + handleCreateFlow(credentialManagerHandler, message, replyCurrent) + } + + GET_UNIQUE_KEY -> this.coroutineScope.launch { + handleGetFlow(credentialManagerHandler, message, replyCurrent) + } + + else -> Log.i(TAG, "Incorrect request json") + } + } + } + + private suspend fun handleCreateFlow( + credentialManagerHandler: CredentialManagerHandler, + message: String, + reply: ReplyChannel, + ) { + try { + havePendingRequest = false + pendingRequestIsDoomed = false + val response = credentialManagerHandler.createPasskey(message) + val successArray = ArrayList(); + successArray.add("success"); + successArray.add(JSONObject(response.registrationResponseJson)); + successArray.add(CREATE_UNIQUE_KEY); + reply.send(JSONArray(successArray).toString()) + replyChannel = null // setting initial replyChannel for the next request + } catch (e: CreateCredentialException) { + reportFailure( + "Error: ${e.errorMessage} w type: ${e.type} w obj: $e", + CREATE_UNIQUE_KEY + ) + } catch (t: Throwable) { + reportFailure("Error: ${t.message}", CREATE_UNIQUE_KEY) + } + } + + companion object { + /** INTERFACE_NAME is the name of the MessagePort that must be injected into pages. */ + const val INTERFACE_NAME = "__webauthn_interface__" + const val TYPE_KEY = "type" + const val REQUEST_KEY = "request" + const val CREATE_UNIQUE_KEY = "create" + const val GET_UNIQUE_KEY = "get" + /** INJECTED_VAL is the minified version of the JavaScript code described at this class + * heading. The non minified form is found at credmanweb/javascript/encode.js.*/ + const val INJECTED_VAL = """ + 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)}; + """ + } + // [END android_identity_create_listener_passkeys] + + // Handles the get flow in a less error-prone way + private suspend fun handleGetFlow( + credentialManagerHandler: CredentialManagerHandler, + message: String, + reply: ReplyChannel, + ) { + try { + havePendingRequest = false + pendingRequestIsDoomed = false + val r = credentialManagerHandler.getPasskey(message) + val successArray = ArrayList(); + successArray.add("success"); + successArray.add(JSONObject( + (r.credential as PublicKeyCredential).authenticationResponseJson)) + successArray.add(GET_UNIQUE_KEY); + reply.send(JSONArray(successArray).toString()) + replyChannel = null // setting initial replyChannel for next request given temp 'reply' + } catch (e: GetCredentialException) { + reportFailure("Error: ${e.errorMessage} w type: ${e.type} w obj: $e", GET_UNIQUE_KEY) + } catch (t: Throwable) { + reportFailure("Error: ${t.message}", GET_UNIQUE_KEY) + } + } + + /** Sends an error result to the page. */ + private fun reportFailure(message: String, type: String) { + havePendingRequest = false + pendingRequestIsDoomed = false + val reply: ReplyChannel = replyChannel!! // verifies non null by throwing NPE + replyChannel = null + postErrorMessage(reply, message, type) + } + + private fun postErrorMessage(reply: ReplyChannel, errorMessage: String, type: String) { + Log.i(TAG, "Sending error message back to the page via replyChannel $errorMessage"); + val array: MutableList = ArrayList() + array.add("error") + array.add(errorMessage) + array.add(type) + reply.send(JSONArray(array).toString()) + var toastMsg = errorMessage + Toast.makeText(this.activity.applicationContext, toastMsg, Toast.LENGTH_SHORT).show() + } + + // [START android_identity_javascript_reply_channel] + // The setup for the reply channel allows communication with JavaScript. + private class JavaScriptReplyChannel(private val reply: JavaScriptReplyProxy) : + ReplyChannel { + override fun send(message: String?) { + try { + reply.postMessage(message!!) + } catch (t: Throwable) { + Log.i(TAG, "Reply failure due to: " + t.message); + } + } + } + + // ReplyChannel is the interface where replies to the embedded site are + // sent. This allows for testing since AndroidX bans mocking its objects. + interface ReplyChannel { + fun send(message: String?) + } + // [END android_identity_javascript_reply_channel] +} \ No newline at end of file diff --git a/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/WebViewMainActivity.kt b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/WebViewMainActivity.kt new file mode 100644 index 000000000..a336754aa --- /dev/null +++ b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/WebViewMainActivity.kt @@ -0,0 +1,77 @@ +package com.example.identity.credentialmanager + +import android.graphics.Bitmap +import android.os.Bundle +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.viewinterop.AndroidView +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import kotlinx.coroutines.CoroutineScope + +class WebViewMainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // [START android_identity_initialize_the_webview] + val credentialManagerHandler = CredentialManagerHandler(this) + + setContent { + val coroutineScope = rememberCoroutineScope() + AndroidView(factory = { + WebView(it).apply { + settings.javaScriptEnabled = true + + // Test URL: + val url = "https://credman-web-test.glitch.me/" + val listenerSupported = WebViewFeature.isFeatureSupported( + WebViewFeature.WEB_MESSAGE_LISTENER + ) + if (listenerSupported) { + // Inject local JavaScript that calls Credential Manager. + hookWebAuthnWithListener( + this, this@WebViewMainActivity, + coroutineScope, credentialManagerHandler + ) + } else { + // Fallback routine for unsupported API levels. + } + loadUrl(url) + } + } + ) + } + // [END android_identity_initialize_the_webview] + } + + /** + * Connects the local app logic with the web page via injection of javascript through a + * WebListener. Handles ensuring the [PasskeyWebListener] is hooked up to the webView page + * if compatible. + */ + fun hookWebAuthnWithListener( + webView: WebView, + activity: WebViewMainActivity, + coroutineScope: CoroutineScope, + credentialManagerHandler: CredentialManagerHandler + ) { + val passkeyWebListener = PasskeyWebListener(activity, coroutineScope, credentialManagerHandler) + val webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + webView.evaluateJavascript(PasskeyWebListener.INJECTED_VAL, null) + } + } + + val rules = setOf("*") + if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { + WebViewCompat.addWebMessageListener(webView, PasskeyWebListener.INTERFACE_NAME, + rules, passkeyWebListener) + } + + webView.webViewClient = webViewClient + } +} \ No newline at end of file