Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -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" }
Expand Down
1 change: 1 addition & 0 deletions identity/credentialmanager/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Any>();
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<Any>();
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<Any?> = 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]
}
Original file line number Diff line number Diff line change
@@ -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
}
}