From 2f6fc36bbed6eddf7a2c5b9dd98cb1d04f63bf4d Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Sun, 21 Sep 2025 23:20:36 -0700 Subject: [PATCH 01/44] Add passkey support with CredentialManager and WebView integration --- common/build.gradle | 2 + .../oauth2/CredentialManagerHandler.kt | 54 ++++ .../providers/oauth2/PasskeyWebListener.kt | 239 ++++++++++++++++++ .../oauth2/WebViewAuthorizationFragment.java | 9 + .../oauth2/WebViewMessageListener.kt | 45 ++++ .../AzureActiveDirectoryWebViewClient.java | 12 + 6 files changed, 361 insertions(+) create mode 100644 common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt create mode 100644 common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt create mode 100644 common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewMessageListener.kt diff --git a/common/build.gradle b/common/build.gradle index 3c6e2e7104..2d21c47aad 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -175,6 +175,8 @@ dependencies { implementation "com.google.android.gms:play-services-fido:$rootProject.ext.LegacyFidoApiVersion" implementation "com.google.android.libraries.identity.googleid:googleid:$rootProject.ext.GoogleIdVersion" implementation "com.github.stephenc.jcip:jcip-annotations:$rootProject.ext.jcipAnnotationVersion" + implementation "androidx.webkit:webkit:1.14.0" + constraints { implementation ("com.squareup.okio:okio:3.4.0") { diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt new file mode 100644 index 0000000000..f592aec50a --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt @@ -0,0 +1,54 @@ +package com.microsoft.identity.common.internal.providers.oauth2 + +import android.app.Activity +import android.os.Build +import android.util.Log +import androidx.credentials.* +import androidx.credentials.exceptions.* + +class CredentialManagerHandler(private val activity: Activity) { + + companion object { + const val TAG = "CredentialManagerHandler" + } + + private val mCredMan = CredentialManager.create(activity.applicationContext) + + /** + * 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 { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + 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 + } + } else { + throw UnsupportedOperationException("Passkey creation requires Android 9 or higher.") + } + } + + /** + * 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 + } + } +} \ No newline at end of file diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt new file mode 100644 index 0000000000..ec0a3cb123 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt @@ -0,0 +1,239 @@ +package com.microsoft.identity.common.internal.providers.oauth2 + +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 + +/** +This web listener looks for the 'postMessage()' call on the javascript web code, and when it +receives it, it will handle it in the manner dictated in this local codebase. This allows for +javascript on the web to interact with the local setup on device that contains more complex logic. + +The embedded javascript can be found in CredentialManagerWebView/javascript/encode.js. +It can be modified depending on the use case. If you wish to minify, please use the following command +to call the toptal minifier API. +``` +cat encode.js | grep -v '^let __webauthn_interface__;$' | \ +curl -X POST --data-urlencode input@- \ +https://www.toptal.com/developers/javascript-minifier/api/raw | tr '"' "'" | pbcopy +``` +pbpaste should output the proper minimized code. In linux, you may have to alias as follows: +``` +alias pbcopy='xclip -selection clipboard' +alias pbpaste='xclip -selection clipboard -o' +``` +in your bashrc. + */ +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 cancelled, 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 when it wants to do a WebAuthn `get` or 'post' request. */ + @UiThread + override fun onPostMessage( + view: WebView, + message: WebMessageCompat, + sourceOrigin: Uri, + isMainFrame: Boolean, + replyProxy: JavaScriptReplyProxy, + ) { + Log.i(TAG, "In Post Message : $message source: $sourceOrigin") + 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, "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 + } + + // Only allow requests from known origins. + if (sourceOrigin.toString() != "https://login.microsoft.com") { + // TODO: Add all other known supported origins here (e.g., UsGov and other clouds) + reportFailure("WebAuthN not permitted for origin: ${sourceOrigin}", type) + return + } + + havePendingRequest = true + pendingRequestIsDoomed = false + + // Let’s 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, "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") + } + } + } + + // 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) + } + } + + // handles the create flow in a less error prone way + 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 next request given temp 'reply' + } 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) + } + } + + /** Invalidates any current request. */ + fun onPageStarted() { + if (havePendingRequest) { + pendingRequestIsDoomed = true + } + } + + /** 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() + } + + 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 over which replies to the embedded site are sent. This allows + for testing because AndroidX bans mocking its objects.*/ + interface ReplyChannel { + fun send(message: String?) + } + + companion object { + /** INTERFACE_NAME is the name of the MessagePort that must be injected into pages. */ + const val INTERFACE_NAME = "__webauthn_interface__" + + const val CREATE_UNIQUE_KEY = "create" + const val GET_UNIQUE_KEY = "get" + const val TYPE_KEY = "type" + const val REQUEST_KEY = "request" + + /** 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){__webauthn_interface__.addEventListener("message",function e(n){var r=JSON.parse(n.data),t=r[2];"get"===t?l(r):"create"===t?u(r):console.log("Incorrect response format for reply")});var n=null,r=null,t=null,a=null;function l(e){if(null===n||null===t){console.log("Reply failure: Resolve: "+r+" and reject: "+a);return}if("success"!=e[0]){var l=t;n=null,t=null,l(new DOMException(e[1],"NotAllowedError"));return}var o=i(e[1]),s=n;n=null,t=null,s(o)}function o(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("Reply failure: Resolve: "+r+" and reject: "+a);return}if("success"!=e[0]){var n=a;r=null,a=null,n(new DOMException(e[1],"NotAllowedError"));return}var t=i(e[1]),l=r;r=null,a=null,l(t)}function i(e){return e.rawId=o(e.rawId),e.response.clientDataJSON=o(e.response.clientDataJSON),e.response.hasOwnProperty("attestationObject")&&(e.response.attestationObject=o(e.response.attestationObject)),e.response.hasOwnProperty("authenticatorData")&&(e.response.authenticatorData=o(e.response.authenticatorData)),e.response.hasOwnProperty("signature")&&(e.response.signature=o(e.response.signature)),e.response.hasOwnProperty("userHandle")&&(e.response.userHandle=o(e.response.userHandle)),e.getClientExtensionResults=function e(){return{}},e}e.create=function n(t){if(!("publicKey"in t))return e.originalCreateFunction(t);var l=new Promise(function(e,n){r=e,a=n}),o=t.publicKey;if(o.hasOwnProperty("challenge")){var u=s(o.challenge);o.challenge=u}if(o.hasOwnProperty("user")&&o.user.hasOwnProperty("id")){var i=s(o.user.id);o.user.id=i}if(o.hasOwnProperty("excludeCredentials")&&Array.isArray(o.excludeCredentials)&&o.excludeCredentials.length>0)for(var c=0;c + // Handle messages coming from JS + val data = message.data + // Example: log or send back a reply + replyProxy.postMessage("Android received: $data") + } + + fun setup(webView: WebView, activity: Activity) { + WebView.setWebContentsDebuggingEnabled(true) + val coroutineScope = CoroutineScope(kotlinx.coroutines.Dispatchers.Main) + val credentialManagerHandler = CredentialManagerHandler(activity) + + val rules = setOf("*") + if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { + // Add listener for all origins — you can restrict to specific origins instead + WebViewCompat.addWebMessageListener( + webView, + PasskeyWebListener.INTERFACE_NAME, + rules, + PasskeyWebListener(activity,coroutineScope, credentialManagerHandler) + ) + } else { + // Fallback if feature not supported + println("WEB_MESSAGE_LISTENER not supported on this device/WebView.") + } + } +} diff --git a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java index 32f2bd0046..5c2a7b8f12 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java @@ -27,6 +27,7 @@ import android.content.ActivityNotFoundException; import android.content.ComponentName; import android.content.Intent; +import android.graphics.Bitmap; import android.net.Uri; import android.os.Build; import android.os.Handler; @@ -51,6 +52,7 @@ import com.microsoft.identity.common.internal.fido.IFidoManager; import com.microsoft.identity.common.internal.fido.LegacyFido2ApiManager; import com.microsoft.identity.common.internal.providers.oauth2.AuthorizationActivity; +import com.microsoft.identity.common.internal.providers.oauth2.PasskeyWebListener; import com.microsoft.identity.common.internal.providers.oauth2.WebViewAuthorizationFragment; import com.microsoft.identity.common.internal.ui.webview.certbasedauth.AbstractSmartcardCertBasedAuthChallengeHandler; import com.microsoft.identity.common.internal.ui.webview.certbasedauth.AbstractCertBasedAuthChallengeHandler; @@ -1107,6 +1109,16 @@ public void onReceived(@Nullable final AbstractCertBasedAuthChallengeHandler cha }); } + + @Override + public void onPageStarted(final WebView view, + final String url, + final Bitmap favicon) { + super.onPageStarted(view, url, favicon); + view.evaluateJavascript(PasskeyWebListener.INJECTED_VAL, null); + + } + /** * Cleanup to be done when host activity is being destroyed. */ From a43bfef1ab149f160d52a5515fde01b2541e782e Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Tue, 23 Sep 2025 12:55:48 -0700 Subject: [PATCH 02/44] debug --- .../providers/oauth2/CredentialManagerHandler.kt | 16 +++++++++++++--- .../providers/oauth2/PasskeyWebListener.kt | 12 ++++++------ .../oauth2/WebViewAuthorizationFragment.java | 10 ++++++++++ .../providers/oauth2/WebViewMessageListener.kt | 11 +++++++++-- .../AzureActiveDirectoryWebViewClient.java | 2 ++ .../identity/common/logging/Logger.java | 2 +- .../identity/common/java/logging/Logger.java | 4 +--- 7 files changed, 42 insertions(+), 15 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt index f592aec50a..f1c9b78b22 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt @@ -5,6 +5,7 @@ import android.os.Build import android.util.Log import androidx.credentials.* import androidx.credentials.exceptions.* +import com.microsoft.identity.common.logging.Logger class CredentialManagerHandler(private val activity: Activity) { @@ -21,16 +22,22 @@ class CredentialManagerHandler(private val activity: Activity) { * @return [CreatePublicKeyCredentialResponse] containing the result of the credential creation. */ suspend fun createPasskey(request: String): CreatePublicKeyCredentialResponse { + val methodTag = "$TAG:createPasskey" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + Logger.info(methodTag, "Creating passkey with request: $request") val createRequest = CreatePublicKeyCredentialRequest(request) try { - return mCredMan.createCredential(activity, createRequest) as CreatePublicKeyCredentialResponse + Logger.info(methodTag, "Invoking CredentialManager.createCredential") + val response = mCredMan.createCredential(activity, createRequest) as CreatePublicKeyCredentialResponse + Logger.info(methodTag, "Passkey created successfully.") + return response } 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}") + Logger.error(TAG, "Error creating credential: ErrMessage: ${e.errorMessage}, ErrType: ${e.type}", e) throw e } } else { + Logger.warn(methodTag, "Passkey creation is not supported on Android versions below 9 (Pie). Current version: ${Build.VERSION.SDK_INT}") throw UnsupportedOperationException("Passkey creation requires Android 9 or higher.") } } @@ -42,12 +49,15 @@ class CredentialManagerHandler(private val activity: Activity) { * @return [GetCredentialResponse] containing the result of the credential retrieval. */ suspend fun getPasskey(request: String): GetCredentialResponse { + val methodTag = "$TAG:getPasskey" + Logger.info(methodTag, "Getting passkey with request: $request") val getRequest = GetCredentialRequest(listOf(GetPublicKeyCredentialOption(request, null))) try { + Logger.info(methodTag, "Invoking CredentialManager.getCredential") 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}") + Logger.error(TAG, "Error retrieving credential: ${e.message}", e) throw e } } diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt index ec0a3cb123..37cc9d503e 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt @@ -2,7 +2,6 @@ package com.microsoft.identity.common.internal.providers.oauth2 import android.app.Activity import android.net.Uri -import android.util.Log import android.webkit.WebView import android.widget.Toast import androidx.annotation.UiThread @@ -12,6 +11,7 @@ import androidx.credentials.exceptions.GetCredentialException import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat +import com.microsoft.identity.common.logging.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.json.JSONArray @@ -64,7 +64,7 @@ class PasskeyWebListener( isMainFrame: Boolean, replyProxy: JavaScriptReplyProxy, ) { - Log.i(TAG, "In Post Message : $message source: $sourceOrigin") + Logger.info(TAG, "In Post Message : $message source: $sourceOrigin") val messageData = message.data ?: return onRequest(messageData, sourceOrigin, isMainFrame, JavaScriptReplyChannel(replyProxy)) } @@ -110,7 +110,7 @@ class PasskeyWebListener( // the main “replyChannel” variable to null so it’s ready for the next request. val replyCurrent = replyChannel if (replyCurrent == null) { - Log.i(TAG, "reply channel was null, cannot continue") + Logger.info(TAG, "reply channel was null, cannot continue") return } @@ -122,7 +122,7 @@ class PasskeyWebListener( GET_UNIQUE_KEY -> this.coroutineScope.launch { handleGetFlow(credentialManagerHandler, message, replyCurrent) } - else -> Log.i(TAG, "Incorrect request json") + else -> Logger.info(TAG, "Incorrect request json") } } } @@ -192,7 +192,7 @@ class PasskeyWebListener( } private fun postErrorMessage(reply: ReplyChannel, errorMessage: String, type: String) { - Log.i(TAG, "Sending error message back to the page via replyChannel $errorMessage") + Logger.info(TAG, "Sending error message back to the page via replyChannel $errorMessage") val array: MutableList = ArrayList() array.add("error") array.add(errorMessage) @@ -208,7 +208,7 @@ class PasskeyWebListener( try { reply.postMessage(message!!) }catch (t: Throwable) { - Log.i(TAG, "Reply failure due to: " + t.message); + Logger.info(TAG, "Reply failure due to: " + t.message); } } } diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java index 5db4d23fe4..a8536d1a61 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java @@ -42,6 +42,7 @@ import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; +import android.webkit.ConsoleMessage; import android.webkit.PermissionRequest; import android.webkit.WebChromeClient; import android.webkit.WebSettings; @@ -333,6 +334,7 @@ public boolean onTouch(final View view, final MotionEvent event) { mWebView.setWebViewClient(webViewClient); + WebViewMessageListener.INSTANCE.setup(mWebView, requireActivity()); @@ -358,6 +360,14 @@ public Bitmap getDefaultVideoPoster() { // We will return a 10x10 empty image, instead of the default grey playback image. #2424 return Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888); } + + @Override + public boolean onConsoleMessage(ConsoleMessage consoleMessage) { + Logger.info(methodTag, consoleMessage.message() + " -- From line " + + consoleMessage.lineNumber() + " of " + consoleMessage.sourceId()); + return true; + } + }); } diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewMessageListener.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewMessageListener.kt index 2e113131b6..d966974d31 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewMessageListener.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewMessageListener.kt @@ -9,7 +9,10 @@ import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewCompat.WebMessageListener import androidx.webkit.WebViewFeature +import com.microsoft.identity.common.logging.Logger import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob object WebViewMessageListener { @@ -24,12 +27,16 @@ object WebViewMessageListener { } fun setup(webView: WebView, activity: Activity) { + val methodTag = "WebViewMessageListener:setup" + Logger.info(methodTag, "Setting up WebView message listener") WebView.setWebContentsDebuggingEnabled(true) - val coroutineScope = CoroutineScope(kotlinx.coroutines.Dispatchers.Main) + val coroutineScope = CoroutineScope(Dispatchers.Default) + //val coroutineScope = CoroutineScope(kotlinx.coroutines.Dispatchers.IO + SupervisorJob()) val credentialManagerHandler = CredentialManagerHandler(activity) val rules = setOf("*") if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { + Logger.info(methodTag, "WEB_MESSAGE_LISTENER supported on this device/WebView.") // Add listener for all origins — you can restrict to specific origins instead WebViewCompat.addWebMessageListener( webView, @@ -39,7 +46,7 @@ object WebViewMessageListener { ) } else { // Fallback if feature not supported - println("WEB_MESSAGE_LISTENER not supported on this device/WebView.") + Logger.warn(methodTag, "WEB_MESSAGE_LISTENER not supported on this device/WebView.") } } } diff --git a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java index 5c2a7b8f12..a22bd5e67e 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java @@ -1115,7 +1115,9 @@ public void onPageStarted(final WebView view, final String url, final Bitmap favicon) { super.onPageStarted(view, url, favicon); + Logger.info(TAG, "onPageStarted: Inject JS"); view.evaluateJavascript(PasskeyWebListener.INJECTED_VAL, null); + view.evaluateJavascript("console.log('evaluate works')", null); } diff --git a/common/src/main/java/com/microsoft/identity/common/logging/Logger.java b/common/src/main/java/com/microsoft/identity/common/logging/Logger.java index 5c6abaebad..9ed1ef0707 100644 --- a/common/src/main/java/com/microsoft/identity/common/logging/Logger.java +++ b/common/src/main/java/com/microsoft/identity/common/logging/Logger.java @@ -40,7 +40,7 @@ public class Logger { private static final Logger INSTANCE = new Logger(); // Disable to Logcat logging by default. - private static boolean sAllowLogcat = false; + private static boolean sAllowLogcat = true; /** * Enum class for LogLevel that the sdk recognizes. diff --git a/common4j/src/main/com/microsoft/identity/common/java/logging/Logger.java b/common4j/src/main/com/microsoft/identity/common/java/logging/Logger.java index 0dc3862b0f..2ce6cfa7d3 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/logging/Logger.java +++ b/common4j/src/main/com/microsoft/identity/common/java/logging/Logger.java @@ -472,9 +472,7 @@ private static void log(final String tag, @Nullable final String objectToLog, final Throwable throwable, final boolean containsPII) { - if ((sLogLevel == LogLevel.NO_LOG) || logLevel.compareTo(sLogLevel) > 0 || (!sAllowPii && containsPII)) { - return; - } + final Date now = new Date(); final String diagnosticMetadata = getDiagnosticContextMetadata(correlationId); From a02b892d1a70023412428d646bf038bdede2715a Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Tue, 23 Sep 2025 19:24:36 -0700 Subject: [PATCH 03/44] Log success response in PasskeyWebListener and update JavaScript message handling --- .../common/internal/providers/oauth2/PasskeyWebListener.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt index 37cc9d503e..c5f78e3190 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt @@ -166,6 +166,7 @@ class PasskeyWebListener( successArray.add(JSONObject(response.registrationResponseJson)) successArray.add(CREATE_UNIQUE_KEY) reply.send(JSONArray(successArray).toString()) + Logger.info(TAG, successArray.joinToString { it.toString() }) replyChannel = null // setting initial replyChannel for next request given temp 'reply' } catch (e: CreateCredentialException) { reportFailure("Error: ${e.errorMessage} w type: ${e.type} w obj: $e", @@ -231,8 +232,9 @@ class PasskeyWebListener( /** 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){__webauthn_interface__.addEventListener("message",function e(n){var r=JSON.parse(n.data),t=r[2];"get"===t?l(r):"create"===t?u(r):console.log("Incorrect response format for reply")});var n=null,r=null,t=null,a=null;function l(e){if(null===n||null===t){console.log("Reply failure: Resolve: "+r+" and reject: "+a);return}if("success"!=e[0]){var l=t;n=null,t=null,l(new DOMException(e[1],"NotAllowedError"));return}var o=i(e[1]),s=n;n=null,t=null,s(o)}function o(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("Reply failure: Resolve: "+r+" and reject: "+a);return}if("success"!=e[0]){var n=a;r=null,a=null,n(new DOMException(e[1],"NotAllowedError"));return}var t=i(e[1]),l=r;r=null,a=null,l(t)}function i(e){return e.rawId=o(e.rawId),e.response.clientDataJSON=o(e.response.clientDataJSON),e.response.hasOwnProperty("attestationObject")&&(e.response.attestationObject=o(e.response.attestationObject)),e.response.hasOwnProperty("authenticatorData")&&(e.response.authenticatorData=o(e.response.authenticatorData)),e.response.hasOwnProperty("signature")&&(e.response.signature=o(e.response.signature)),e.response.hasOwnProperty("userHandle")&&(e.response.userHandle=o(e.response.userHandle)),e.getClientExtensionResults=function e(){return{}},e}e.create=function n(t){if(!("publicKey"in t))return e.originalCreateFunction(t);var l=new Promise(function(e,n){r=e,a=n}),o=t.publicKey;if(o.hasOwnProperty("challenge")){var u=s(o.challenge);o.challenge=u}if(o.hasOwnProperty("user")&&o.user.hasOwnProperty("id")){var i=s(o.user.id);o.user.id=i}if(o.hasOwnProperty("excludeCredentials")&&Array.isArray(o.excludeCredentials)&&o.excludeCredentials.length>0)for(var c=0;c0)for(var c=0;c Date: Wed, 8 Oct 2025 16:04:26 -0700 Subject: [PATCH 04/44] Enhance WebView message handling and update Gradle configurations - Set project-level archives name in build.gradle - Improve PasskeyWebListener to set up WebView message listener - Refactor WebViewAuthorizationFragment to handle console messages with logging levels - Update request headers management in WebViewAuthorizationFragment for passkey protocol - Clean up WebViewMessageListener by removing unused default listener --- common/build.gradle | 95 ++++++++++++------- .../providers/oauth2/PasskeyWebListener.kt | 27 +++++- .../oauth2/WebViewAuthorizationFragment.java | 55 +++++++---- .../oauth2/WebViewMessageListener.kt | 9 +- 4 files changed, 125 insertions(+), 61 deletions(-) diff --git a/common/build.gradle b/common/build.gradle index c5db106f8d..1ad9f0c1d1 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -49,6 +49,9 @@ boolean newBrokerDiscoveryEnabledFlag = project.hasProperty("newBrokerDiscoveryE boolean trustDebugBrokerFlag = project.hasProperty("trustDebugBrokerFlag") boolean bypassRedirectUriCheck = project.hasProperty("bypassRedirectUriCheck") +// Set the archives name at project level +base.archivesName = "common" + android { compileSdk rootProject.ext.compileSdkVersion defaultConfig { @@ -58,7 +61,6 @@ android { targetSdkVersion rootProject.ext.targetSdkVersion versionCode getAppVersionCode() versionName getAppVersionName() - project.archivesBaseName = "common" project.version = android.defaultConfig.versionName testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" //Languages Common supports. @@ -145,7 +147,18 @@ android { libraryVariants.all { variant -> variant.outputs.all { - outputFileName = "${archivesBaseName}-${version}.aar" + outputFileName = "${base.archivesName.get()}-${version}.aar" + } + } + // 👇 Register your custom variants as publishable + publishing { + singleVariant("distRelease") { + withSourcesJar() + withJavadocJar() + } + singleVariant("distDebug") { + withSourcesJar() + withJavadocJar() } } } @@ -308,71 +321,84 @@ tasks.register('pmd', Pmd) { html.required = true } } + // For publishing to the remote maven repo. afterEvaluate { - publishing { publications { - distRelease(MavenPublication) { - from components.distRelease - groupId 'com.microsoft.identity' - artifactId 'common' - //Edit the 'version' here for VSTS RC build + // --- distRelease Publication --- + create("distRelease", MavenPublication) { + from(components["distRelease"]) + + groupId = "com.microsoft.identity" + artifactId = "common" version = project.version pom { - name = 'common' - description = 'This library contains code shared between the Active Directory ' + - 'Authentication Library (ADAL) for Android and the Microsoft ' + - 'Authentication Library (MSAL) for Android. This library ' + - 'includes only internal classes and is NOT part of the ' + - 'public API' - url = 'https://github.com/AzureAD/microsoft-authentication-library-common-for-android' + name.set("common") + description.set(""" + This library contains code shared between the Active Directory + Authentication Library (ADAL) for Android and the Microsoft + Authentication Library (MSAL) for Android. This library + includes only internal classes and is NOT part of the + public API. + """.stripIndent()) + url.set("https://github.com/AzureAD/microsoft-authentication-library-common-for-android") + developers { developer { - id = 'microsoft' - name = 'Microsoft' + id.set("microsoft") + name.set("Microsoft") } } + licenses { license { - name = 'MIT License' + name.set("MIT License") } } - inceptionYear = '2017' + + inceptionYear.set("2017") + scm { - url = 'https://github.com/AzureAD/microsoft-authentication-library-common-for-android/tree/master' + url.set("https://github.com/AzureAD/microsoft-authentication-library-common-for-android/tree/master") } - properties = [ - branch : 'master', - version: project.version - ] + + properties.set([ + "branch": "master", + "version": version + ]) } } - distDebug(MavenPublication) { - from components.distDebug - groupId 'com.microsoft.identity' - artifactId 'common-debug' - //Edit the 'version' here for VSTS RC build + // --- distDebug Publication --- + create("distDebug", MavenPublication) { + from(components["distDebug"]) + + groupId = "com.microsoft.identity" + artifactId = "common-debug" version = project.version } } - // Repositories to which Gradle can publish artifacts repositories { maven { - name "vsts-maven-adal-android" - url "https://identitydivision.pkgs.visualstudio.com/_packaging/AndroidADAL/maven/v1" + name = "vsts-maven-adal-android" + url = uri("https://identitydivision.pkgs.visualstudio.com/_packaging/AndroidADAL/maven/v1") + credentials { - username System.getenv("ENV_VSTS_MVN_ANDROIDCOMMON_USERNAME") != null ? System.getenv("ENV_VSTS_MVN_ANDROIDCOMMON_USERNAME") : project.findProperty("vstsUsername") - password System.getenv("ENV_VSTS_MVN_ANDROIDCOMMON_ACCESSTOKEN") != null ? System.getenv("ENV_VSTS_MVN_ANDROIDCOMMON_ACCESSTOKEN") : project.findProperty("vstsMavenAccessToken") + username = System.getenv("ENV_VSTS_MVN_ANDROIDCOMMON_USERNAME") + ?: project.findProperty("vstsUsername") + password = System.getenv("ENV_VSTS_MVN_ANDROIDCOMMON_ACCESSTOKEN") + ?: project.findProperty("vstsMavenAccessToken") } } } } } + + tasks.configureEach { task -> if (task.name.contains("assemble")) { task.dependsOn 'pmd' @@ -432,4 +458,3 @@ tasks.register("dependenciesSizeCheck") { } } } - diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt index c5f78e3190..77b194e5b5 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt @@ -11,8 +11,10 @@ import androidx.credentials.exceptions.GetCredentialException import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature import com.microsoft.identity.common.logging.Logger import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.json.JSONArray import org.json.JSONObject @@ -39,8 +41,8 @@ in your bashrc. */ class PasskeyWebListener( private val activity: Activity, - private val coroutineScope: CoroutineScope, - private val credentialManagerHandler: CredentialManagerHandler + private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Default), + private val credentialManagerHandler: CredentialManagerHandler = CredentialManagerHandler(activity) ) : WebViewCompat.WebMessageListener { /** havePendingRequest is true if there is an outstanding WebAuthn request. There is only ever @@ -69,6 +71,27 @@ class PasskeyWebListener( onRequest(messageData, sourceOrigin, isMainFrame, JavaScriptReplyChannel(replyProxy)) } + /** + * Sets up the WebView to listen for messages from the embedded page. + * @param webView The WebView to attach the listener to. + * + */ + fun hook(webView: WebView) { + val methodTag = "$TAG:hook" + Logger.info(methodTag, "Setting up WebView message listener") + WebView.setWebContentsDebuggingEnabled(true) + val rules = setOf("*") + if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { + Logger.info(methodTag, "WEB_MESSAGE_LISTENER supported on this device/WebView.") + // Add listener for all origins — you can restrict to specific origins instead + WebViewCompat.addWebMessageListener(webView, INTERFACE_NAME, rules, this) + } else { + // Fallback if feature not supported + Logger.warn(methodTag, "WEB_MESSAGE_LISTENER not supported on this device/WebView.") + } + } + + private fun onRequest( msg: String, sourceOrigin: Uri, diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java index a8536d1a61..aeed79b4ca 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java @@ -36,6 +36,7 @@ import android.content.Context; import android.content.Intent; import android.graphics.Bitmap; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.view.LayoutInflater; @@ -75,12 +76,15 @@ import com.microsoft.identity.common.java.providers.RawAuthorizationResult; import com.microsoft.identity.common.java.ui.webview.authorization.IAuthorizationCompletionCallback; import com.microsoft.identity.common.java.util.ClientExtraSku; +import com.microsoft.identity.common.java.util.StringUtil; import com.microsoft.identity.common.logging.Logger; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; +import java.util.AbstractMap; import java.util.Arrays; import java.util.HashMap; +import java.util.Map; import static com.microsoft.identity.common.java.AuthenticationConstants.OAuth2.UTID; @@ -362,9 +366,21 @@ public Bitmap getDefaultVideoPoster() { } @Override - public boolean onConsoleMessage(ConsoleMessage consoleMessage) { - Logger.info(methodTag, consoleMessage.message() + " -- From line " + - consoleMessage.lineNumber() + " of " + consoleMessage.sourceId()); + public boolean onConsoleMessage(@NonNull final ConsoleMessage consoleMessage) { + super.onConsoleMessage(consoleMessage); + final String methodTag = TAG + ":onConsoleMessage"; + final String errorMessage = consoleMessage.message() + " -- From line " + + consoleMessage.lineNumber() + " of " + consoleMessage.sourceId(); + switch (consoleMessage.messageLevel()) { + case ERROR: + Logger.error(methodTag, errorMessage, null); + break; + case WARNING: + Logger.warn(methodTag, errorMessage); + break; + default: + Logger.info(methodTag, errorMessage); + } return true; } @@ -420,29 +436,36 @@ public void onDestroy() { */ private HashMap getRequestHeaders(final Bundle state) { try { + final Uri authRequestUri = Uri.parse(mAuthorizationRequestUrl); + final String webAuthNQueryParameter = authRequestUri.getQueryParameter(FidoConstants.WEBAUTHN_QUERY_PARAMETER_FIELD); + final boolean hasWebAuthNQueryParameter = !StringUtil.isNullOrEmpty(webAuthNQueryParameter); // Suppressing unchecked warnings due to casting of serializable String to HashMap @SuppressWarnings(WarningType.unchecked_warning) - HashMap requestHeaders = (HashMap) state.getSerializable(REQUEST_HEADERS); - // In cases of WebView as an auth agent, we want to always add the passkey protocol header. - // (Not going to add passkey protocol header until full feature is ready.) - if (CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_PASSKEY_FEATURE)) { - if (requestHeaders == null) { - requestHeaders = new HashMap<>(); - } - requestHeaders.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_VALUE); - } - + final HashMap requestHeaders = (HashMap) state.getSerializable(REQUEST_HEADERS); + final HashMap headers = requestHeaders != null ? requestHeaders : new HashMap<>(); // Attach client extras header for ESTS telemetry. Only done for broker requests if (isBrokerRequest) { + if (hasWebAuthNQueryParameter && CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_PASSKEY_FEATURE)) { + headers.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_VALUE); + } final ClientExtraSku clientExtraSku = ClientExtraSku.builder() .srcSku(state.getString(PRODUCT)) .srcSkuVer(state.getString(VERSION)) .build(); - requestHeaders.put(com.microsoft.identity.common.java.AuthenticationConstants.SdkPlatformFields.CLIENT_EXTRA_SKU, clientExtraSku.toString()); + headers.put(com.microsoft.identity.common.java.AuthenticationConstants.SdkPlatformFields.CLIENT_EXTRA_SKU, clientExtraSku.toString()); + } else { + if (hasWebAuthNQueryParameter) { + if (headers.containsKey(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME)){ + Logger.warn(TAG + ":getRequestHeaders", "Passkey protocol header already exists in request headers."); + } else { + headers.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_VALUE); + } + } + } - return requestHeaders; + return headers; } catch (Exception e) { - return null; + return new HashMap<>(); } } diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewMessageListener.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewMessageListener.kt index d966974d31..6c95449e38 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewMessageListener.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewMessageListener.kt @@ -17,14 +17,7 @@ import kotlinx.coroutines.SupervisorJob object WebViewMessageListener { - @SuppressLint("RequiresFeature") - private val DEFAULT_LISTENER = - WebMessageListener { view: WebView?, message: WebMessageCompat, sourceOrigin: Uri?, isMainFrame: Boolean, replyProxy: JavaScriptReplyProxy -> - // Handle messages coming from JS - val data = message.data - // Example: log or send back a reply - replyProxy.postMessage("Android received: $data") - } + fun setup(webView: WebView, activity: Activity) { val methodTag = "WebViewMessageListener:setup" From ef52c355c452a41f121b040b204870ecf41ea24d Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 9 Oct 2025 17:05:28 -0700 Subject: [PATCH 05/44] Refactor Passkey protocol handling and enhance WebView integration --- .../internal/fido/FidoChallengeField.kt | 2 +- .../providers/oauth2/PasskeyWebListener.kt | 69 ++++++++++++------- .../oauth2/WebViewAuthorizationFragment.java | 51 +++++++++----- .../oauth2/WebViewMessageListener.kt | 45 ------------ .../AzureActiveDirectoryWebViewClient.java | 16 +++-- .../common/java/constants/FidoConstants.kt | 18 +++-- 6 files changed, 105 insertions(+), 96 deletions(-) delete mode 100644 common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewMessageListener.kt diff --git a/common/src/main/java/com/microsoft/identity/common/internal/fido/FidoChallengeField.kt b/common/src/main/java/com/microsoft/identity/common/internal/fido/FidoChallengeField.kt index 2670d9a767..de4e477354 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/fido/FidoChallengeField.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/fido/FidoChallengeField.kt @@ -112,7 +112,7 @@ data class FidoChallengeField(private val field: FidoRequestField, @Throws(ClientException::class) fun throwIfInvalidProtocolVersion(field: FidoRequestField, value: String?): String { val version = throwIfInvalidRequiredParameter(field, value) - if (version != FidoConstants.PASSKEY_PROTOCOL_VERSION) { + if (version != FidoConstants.PASSKEY_PROTOCOL_VERSION_1_0) { throw ClientException(ClientException.PASSKEY_PROTOCOL_REQUEST_PARSING_ERROR, "Provided protocol version is not currently supported.") } return version diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt index 77b194e5b5..a683035960 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt @@ -12,6 +12,7 @@ import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature +import com.microsoft.identity.common.internal.ui.webview.AzureActiveDirectoryWebViewClient import com.microsoft.identity.common.logging.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -41,8 +42,8 @@ in your bashrc. */ class PasskeyWebListener( private val activity: Activity, - private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Default), - private val credentialManagerHandler: CredentialManagerHandler = CredentialManagerHandler(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 @@ -71,26 +72,6 @@ class PasskeyWebListener( onRequest(messageData, sourceOrigin, isMainFrame, JavaScriptReplyChannel(replyProxy)) } - /** - * Sets up the WebView to listen for messages from the embedded page. - * @param webView The WebView to attach the listener to. - * - */ - fun hook(webView: WebView) { - val methodTag = "$TAG:hook" - Logger.info(methodTag, "Setting up WebView message listener") - WebView.setWebContentsDebuggingEnabled(true) - val rules = setOf("*") - if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { - Logger.info(methodTag, "WEB_MESSAGE_LISTENER supported on this device/WebView.") - // Add listener for all origins — you can restrict to specific origins instead - WebViewCompat.addWebMessageListener(webView, INTERFACE_NAME, rules, this) - } else { - // Fallback if feature not supported - Logger.warn(methodTag, "WEB_MESSAGE_LISTENER not supported on this device/WebView.") - } - } - private fun onRequest( msg: String, @@ -254,11 +235,51 @@ class PasskeyWebListener( /** 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 = """ + const val WEB_AUTHN_INTERFACE_MINIFIED = """ var __webauthn_interface__,__webauthn_hooks__;!function(e){__webauthn_interface__.addEventListener("message",function e(n){console.log(n.data);var r=JSON.parse(n.data),t=r[2];"get"===t?s(r):"create"===t?u(r):console.log("Incorrect response format for reply")});var n=null,r=null,t=null,a=null;function s(e){if(null===n||null===t){console.log("Reply failure: Resolve: "+r+" and reject: "+a);return}if("success"!=e[0]){var s=t;n=null,t=null,s(new DOMException(e[1],"NotAllowedError"));return}var l=i(e[1]),o=n;n=null,t=null,o(l)}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 o(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("Reply failure: Resolve: "+r+" and reject: "+a);return}if("success"!=e[0]){var n=a;r=null,a=null,n(new DOMException(e[1],"NotAllowedError"));return}var t=i(e[1]),s=r;r=null,a=null,s(t)}function i(e){return 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.response.getTransports=function n(){return e.response.hasOwnProperty("transports")?e.response.transports:[]},e}e.create=function n(t){if(!("publicKey"in t))return e.originalCreateFunction(t);var s=new Promise(function(e,n){r=e,a=n}),l=t.publicKey;if(l.hasOwnProperty("challenge")){var u=o(l.challenge);l.challenge=u}if(l.hasOwnProperty("user")&&l.user.hasOwnProperty("id")){var i=o(l.user.id);l.user.id=i}if(l.hasOwnProperty("excludeCredentials")&&Array.isArray(l.excludeCredentials)&&l.excludeCredentials.length>0)for(var c=0;c + if (url != null && ALLOWED_ORIGIN_RULES.any { allowedOrigin -> url.startsWith(allowedOrigin) }) { + webView.evaluateJavascript(WEB_AUTHN_INTERFACE_MINIFIED, null) + } + } + + return true + } + Logger.warn(methodTag, "WEB_MESSAGE_LISTENER not supported on this device/WebView.") + return false + } + + } } \ No newline at end of file diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java index aeed79b4ca..a0c9b57be7 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java @@ -55,7 +55,6 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import androidx.fragment.app.FragmentActivity; -import androidx.webkit.WebViewFeature; import com.microsoft.identity.common.R; import com.microsoft.identity.common.adal.internal.AuthenticationConstants; @@ -81,17 +80,13 @@ import java.io.UnsupportedEncodingException; import java.net.URLEncoder; -import java.util.AbstractMap; import java.util.Arrays; import java.util.HashMap; -import java.util.Map; import static com.microsoft.identity.common.java.AuthenticationConstants.OAuth2.UTID; -import static kotlinx.coroutines.CoroutineScopeKt.CoroutineScope; import io.opentelemetry.api.trace.SpanContext; -import kotlinx.coroutines.Dispatchers; /** * Authorization fragment with embedded webview. @@ -336,19 +331,13 @@ public boolean onTouch(final View view, final MotionEvent event) { mWebView.getSettings().setSupportZoom(webViewZoomEnabled); mWebView.setVisibility(View.INVISIBLE); mWebView.setWebViewClient(webViewClient); - - - - WebViewMessageListener.INSTANCE.setup(mWebView, requireActivity()); - - mWebView.setWebChromeClient(new WebChromeClient() { @Override public void onPermissionRequest(final PermissionRequest request) { requireActivity().runOnUiThread(() -> { // Log the permission request Logger.info(methodTag, - "Permission requested from:" +request.getOrigin() + + "Permission requested from:" + request.getOrigin() + " for resources:" + Arrays.toString(request.getResources()) ); mCameraPermissionRequestHandler.handle(request, requireContext()); @@ -385,6 +374,7 @@ public boolean onConsoleMessage(@NonNull final ConsoleMessage consoleMessage) { } }); + setupPasskeyWebListener(mWebView, webViewClient); } /** @@ -440,13 +430,14 @@ private HashMap getRequestHeaders(final Bundle state) { final String webAuthNQueryParameter = authRequestUri.getQueryParameter(FidoConstants.WEBAUTHN_QUERY_PARAMETER_FIELD); final boolean hasWebAuthNQueryParameter = !StringUtil.isNullOrEmpty(webAuthNQueryParameter); // Suppressing unchecked warnings due to casting of serializable String to HashMap - @SuppressWarnings(WarningType.unchecked_warning) - final HashMap requestHeaders = (HashMap) state.getSerializable(REQUEST_HEADERS); + @SuppressWarnings(WarningType.unchecked_warning) final HashMap requestHeaders = (HashMap) state.getSerializable(REQUEST_HEADERS); final HashMap headers = requestHeaders != null ? requestHeaders : new HashMap<>(); // Attach client extras header for ESTS telemetry. Only done for broker requests if (isBrokerRequest) { if (hasWebAuthNQueryParameter && CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_PASSKEY_FEATURE)) { - headers.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_VALUE); + headers.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_AND_REG); + } else { + headers.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_ONLY); } final ClientExtraSku clientExtraSku = ClientExtraSku.builder() .srcSku(state.getString(PRODUCT)) @@ -455,10 +446,10 @@ private HashMap getRequestHeaders(final Bundle state) { headers.put(com.microsoft.identity.common.java.AuthenticationConstants.SdkPlatformFields.CLIENT_EXTRA_SKU, clientExtraSku.toString()); } else { if (hasWebAuthNQueryParameter) { - if (headers.containsKey(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME)){ + if (headers.containsKey(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME)) { Logger.warn(TAG + ":getRequestHeaders", "Passkey protocol header already exists in request headers."); } else { - headers.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_VALUE); + headers.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_AND_REG); } } @@ -469,6 +460,7 @@ private HashMap getRequestHeaders(final Bundle state) { } } + @Nullable public ActivityResultLauncher getFidoLauncher() { return mFidoLauncher; @@ -512,9 +504,34 @@ private SwitchBrowserProtocolCoordinator getSwitchBrowserCoordinator() { /** * Set the switch browser bundle to be used when resuming the flow. + * * @param bundle The bundle containing the data needed to resume the flow. */ public static synchronized void setSwitchBrowserBundle(@Nullable final Bundle bundle) { switchBrowserBundle = bundle; } + + + /** + * Sets up the PasskeyWebListener if the request headers indicate that both authentication and registration + * are supported. If the hook fails, it downgrades to authentication only. + * Called during WebView setup. + */ + private void setupPasskeyWebListener(@NonNull final WebView webView, + @NonNull final AzureActiveDirectoryWebViewClient webViewClient) { + final String methodTag = TAG + ":setupPasskeyWebListener"; + final String passkeyProtocolHeader = mRequestHeaders.get(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME); + if (FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_AND_REG.equals(passkeyProtocolHeader)) { + final boolean passkeyWebListenerHooked = PasskeyWebListener.hook(webView, requireActivity(), webViewClient); + if (!passkeyWebListenerHooked) { + Logger.warn(methodTag, "PasskeyWebListener hook failed, Downgrading to auth only."); + // Downgrade to auth only + mRequestHeaders.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_ONLY); + } + } else { + Logger.warn(methodTag, "Passkey protocol header not found or not for both auth and reg." + + " Not hooking the PasskeyWebListener."); + } + } + } diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewMessageListener.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewMessageListener.kt deleted file mode 100644 index 6c95449e38..0000000000 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewMessageListener.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.microsoft.identity.common.internal.providers.oauth2 - -import android.annotation.SuppressLint -import android.app.Activity -import android.net.Uri -import android.webkit.WebView -import androidx.webkit.JavaScriptReplyProxy -import androidx.webkit.WebMessageCompat -import androidx.webkit.WebViewCompat -import androidx.webkit.WebViewCompat.WebMessageListener -import androidx.webkit.WebViewFeature -import com.microsoft.identity.common.logging.Logger -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob - - -object WebViewMessageListener { - - - - fun setup(webView: WebView, activity: Activity) { - val methodTag = "WebViewMessageListener:setup" - Logger.info(methodTag, "Setting up WebView message listener") - WebView.setWebContentsDebuggingEnabled(true) - val coroutineScope = CoroutineScope(Dispatchers.Default) - //val coroutineScope = CoroutineScope(kotlinx.coroutines.Dispatchers.IO + SupervisorJob()) - val credentialManagerHandler = CredentialManagerHandler(activity) - - val rules = setOf("*") - if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { - Logger.info(methodTag, "WEB_MESSAGE_LISTENER supported on this device/WebView.") - // Add listener for all origins — you can restrict to specific origins instead - WebViewCompat.addWebMessageListener( - webView, - PasskeyWebListener.INTERFACE_NAME, - rules, - PasskeyWebListener(activity,coroutineScope, credentialManagerHandler) - ) - } else { - // Fallback if feature not supported - Logger.warn(methodTag, "WEB_MESSAGE_LISTENER not supported on this device/WebView.") - } - } -} diff --git a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java index 18ff5b7eb1..2ca5a17981 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java @@ -52,7 +52,6 @@ import com.microsoft.identity.common.internal.fido.IFidoManager; import com.microsoft.identity.common.internal.fido.LegacyFido2ApiManager; import com.microsoft.identity.common.internal.providers.oauth2.AuthorizationActivity; -import com.microsoft.identity.common.internal.providers.oauth2.PasskeyWebListener; import com.microsoft.identity.common.internal.providers.oauth2.WebViewAuthorizationFragment; import com.microsoft.identity.common.internal.ui.webview.certbasedauth.AbstractSmartcardCertBasedAuthChallengeHandler; import com.microsoft.identity.common.internal.ui.webview.certbasedauth.AbstractCertBasedAuthChallengeHandler; @@ -95,6 +94,7 @@ import java.util.Locale; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; import static com.microsoft.identity.common.adal.internal.AuthenticationConstants.Broker.AMAZON_APP_REDIRECT_PREFIX; import static com.microsoft.identity.common.adal.internal.AuthenticationConstants.Broker.AZURE_AUTHENTICATOR_APP_PACKAGE_NAME; @@ -140,6 +140,13 @@ public class AzureActiveDirectoryWebViewClient extends OAuth2WebViewClient { private final String mUtid; + private Consumer mRunOnPageStarted; + + // Method to assign the function + public void setRunOnPageStarted(@NonNull final Consumer fun) { + this.mRunOnPageStarted = fun; + } + public AzureActiveDirectoryWebViewClient(@NonNull final Activity activity, @NonNull final IAuthorizationCompletionCallback completionCallback, @NonNull final OnPageLoadedCallback pageLoadedCallback, @@ -1142,10 +1149,9 @@ public void onPageStarted(final WebView view, final String url, final Bitmap favicon) { super.onPageStarted(view, url, favicon); - Logger.info(TAG, "onPageStarted: Inject JS"); - view.evaluateJavascript(PasskeyWebListener.INJECTED_VAL, null); - view.evaluateJavascript("console.log('evaluate works')", null); - + if(mRunOnPageStarted != null) { + mRunOnPageStarted.accept(url); + } } /** diff --git a/common4j/src/main/com/microsoft/identity/common/java/constants/FidoConstants.kt b/common4j/src/main/com/microsoft/identity/common/java/constants/FidoConstants.kt index c77fb56263..fdbf7882da 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/constants/FidoConstants.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/constants/FidoConstants.kt @@ -68,9 +68,14 @@ class FidoConstants { const val PASSKEY_PROTOCOL_HEADER_NAME = "x-ms-PassKeyAuth" /** - * Version of the passkey protocol that we want to use. + * version number of the passkey protocol for authentication only. */ - const val PASSKEY_PROTOCOL_VERSION = "1.0" + const val PASSKEY_PROTOCOL_VERSION_1_0 = "1.0" + + /** + * Version number of the passkey protocol for authentication and registration. + */ + const val PASSKEY_PROTOCOL_VERSION_1_1 = "1.1" /** * Constant to put in PASSKEY_PROTOCOL_KEY_TYPES_SUPPORTED if we support passkeys. @@ -101,9 +106,14 @@ class FidoConstants { const val PASSKEY_PROTOCOL_KEY_TYPES_SUPPORTED = PASSKEY_PROTOCOL_KEY_TYPES_PASSKEY_OPTION /** - * Corresponding value to the passkey protocol header. + * Corresponding value to the passkey protocol header for authentication only. + */ + const val PASSKEY_PROTOCOL_HEADER_AUTH_ONLY = "$PASSKEY_PROTOCOL_VERSION_1_0/$PASSKEY_PROTOCOL_KEY_TYPES_SUPPORTED" + + /** + * Corresponding value to the passkey protocol header for authentication and registration. */ - const val PASSKEY_PROTOCOL_HEADER_VALUE = "$PASSKEY_PROTOCOL_VERSION/$PASSKEY_PROTOCOL_KEY_TYPES_SUPPORTED" + const val PASSKEY_PROTOCOL_HEADER_AUTH_AND_REG = "$PASSKEY_PROTOCOL_VERSION_1_1/$PASSKEY_PROTOCOL_KEY_TYPES_SUPPORTED" /** * Error messages sent to ESTS via the protocol should have a prefix attached. From 150aea2fa0fe9f03c2528aae455d0b91a66b9e77 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Fri, 10 Oct 2025 12:11:58 -0700 Subject: [PATCH 06/44] Add passkey registration flight control and update WebView header handling --- .../providers/oauth2/WebViewAuthorizationFragment.java | 9 ++++++++- .../identity/common/java/flighting/CommonFlight.java | 6 ++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java index a0c9b57be7..17976279f0 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java @@ -429,14 +429,18 @@ private HashMap getRequestHeaders(final Bundle state) { final Uri authRequestUri = Uri.parse(mAuthorizationRequestUrl); final String webAuthNQueryParameter = authRequestUri.getQueryParameter(FidoConstants.WEBAUTHN_QUERY_PARAMETER_FIELD); final boolean hasWebAuthNQueryParameter = !StringUtil.isNullOrEmpty(webAuthNQueryParameter); + final boolean isPasskeyRegistrationFlightEnabled = CommonFlightsManager.INSTANCE + .getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_PASSKEY_REGISTRATION); // Suppressing unchecked warnings due to casting of serializable String to HashMap @SuppressWarnings(WarningType.unchecked_warning) final HashMap requestHeaders = (HashMap) state.getSerializable(REQUEST_HEADERS); final HashMap headers = requestHeaders != null ? requestHeaders : new HashMap<>(); // Attach client extras header for ESTS telemetry. Only done for broker requests if (isBrokerRequest) { - if (hasWebAuthNQueryParameter && CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_PASSKEY_FEATURE)) { + if (hasWebAuthNQueryParameter && isPasskeyRegistrationFlightEnabled) { headers.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_AND_REG); } else { + // If the webauthn query parameter is not present, we should add the header for auth only. + // This to keep the behavior same as before the passkey registration feature was added. headers.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_ONLY); } final ClientExtraSku clientExtraSku = ClientExtraSku.builder() @@ -446,6 +450,9 @@ private HashMap getRequestHeaders(final Bundle state) { headers.put(com.microsoft.identity.common.java.AuthenticationConstants.SdkPlatformFields.CLIENT_EXTRA_SKU, clientExtraSku.toString()); } else { if (hasWebAuthNQueryParameter) { + // Only first party app will be sending the webauthn query parameter and therefore + // they should have declare association in the assetlinks.json file. + // OneAuth and MSAL will control the version of the passkey protocol header on their end. if (headers.containsKey(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME)) { Logger.warn(TAG + ":getRequestHeaders", "Passkey protocol header already exists in request headers."); } else { diff --git a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java index de4e15841b..23a5eff2ac 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java +++ b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java @@ -60,6 +60,12 @@ public enum CommonFlight implements IFlightConfig { */ ENABLE_PASSKEY_FEATURE("EnablePasskeyFeature", true), + /** + * Flight to be able to disable/rollback the passkey feature in broker if necessary. + * This will be set to true by default. + */ + ENABLE_PASSKEY_REGISTRATION("EnablePasskeyRegistration", true), + /** * Flight to control the timeout duration for UrlConnection connect timeout. */ From 89e0f50755c6c014df765fe703f791e0d05d5b6d Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Fri, 10 Oct 2025 14:32:20 -0700 Subject: [PATCH 07/44] Refactor PasskeyWebListener and WebViewAuthorizationFragment for improved script handling and logging; add JsScriptRecord for script management; update CommonFlight to disable passkey feature by default. --- .../providers/oauth2/PasskeyWebListener.kt | 18 +++--- .../oauth2/WebViewAuthorizationFragment.java | 10 ++++ .../AzureActiveDirectoryWebViewClient.java | 39 ++++++++----- .../internal/ui/webview/JsScriptRecord.kt | 58 +++++++++++++++++++ .../common/java/flighting/CommonFlight.java | 4 +- 5 files changed, 105 insertions(+), 24 deletions(-) create mode 100644 common/src/main/java/com/microsoft/identity/common/internal/ui/webview/JsScriptRecord.kt diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt index a683035960..1b4aede70c 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt @@ -226,7 +226,7 @@ class PasskeyWebListener( companion object { /** INTERFACE_NAME is the name of the MessagePort that must be injected into pages. */ - const val INTERFACE_NAME = "__webauthn_interface__" + private const val INTERFACE_NAME = "__webauthn_interface__" const val CREATE_UNIQUE_KEY = "create" const val GET_UNIQUE_KEY = "get" @@ -235,7 +235,7 @@ class PasskeyWebListener( /** 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 WEB_AUTHN_INTERFACE_MINIFIED = """ + const val WEB_AUTHN_INTERFACE_JS_MINIFIED = """ var __webauthn_interface__,__webauthn_hooks__;!function(e){__webauthn_interface__.addEventListener("message",function e(n){console.log(n.data);var r=JSON.parse(n.data),t=r[2];"get"===t?s(r):"create"===t?u(r):console.log("Incorrect response format for reply")});var n=null,r=null,t=null,a=null;function s(e){if(null===n||null===t){console.log("Reply failure: Resolve: "+r+" and reject: "+a);return}if("success"!=e[0]){var s=t;n=null,t=null,s(new DOMException(e[1],"NotAllowedError"));return}var l=i(e[1]),o=n;n=null,t=null,o(l)}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 o(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("Reply failure: Resolve: "+r+" and reject: "+a);return}if("success"!=e[0]){var n=a;r=null,a=null,n(new DOMException(e[1],"NotAllowedError"));return}var t=i(e[1]),s=r;r=null,a=null,s(t)}function i(e){return 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.response.getTransports=function n(){return e.response.hasOwnProperty("transports")?e.response.transports:[]},e}e.create=function n(t){if(!("publicKey"in t))return e.originalCreateFunction(t);var s=new Promise(function(e,n){r=e,a=n}),l=t.publicKey;if(l.hasOwnProperty("challenge")){var u=o(l.challenge);l.challenge=u}if(l.hasOwnProperty("user")&&l.user.hasOwnProperty("id")){var i=o(l.user.id);l.user.id=i}if(l.hasOwnProperty("excludeCredentials")&&Array.isArray(l.excludeCredentials)&&l.excludeCredentials.length>0)for(var c=0;c - if (url != null && ALLOWED_ORIGIN_RULES.any { allowedOrigin -> url.startsWith(allowedOrigin) }) { - webView.evaluateJavascript(WEB_AUTHN_INTERFACE_MINIFIED, null) - } - } - + webClient.addOnPageStartedScript( + TAG, + WEB_AUTHN_INTERFACE_JS_MINIFIED, + ALLOWED_ORIGIN_RULES + ) return true } Logger.warn(methodTag, "WEB_MESSAGE_LISTENER not supported on this device/WebView.") diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java index 17976279f0..501a90c88f 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java @@ -354,8 +354,15 @@ public Bitmap getDefaultVideoPoster() { return Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888); } + /** + * Capture console messages and log them using our logger. + * + * @param consoleMessage The console message from the WebView + * @return returns true if we handled the message. False will let the default handler handle it. + */ @Override public boolean onConsoleMessage(@NonNull final ConsoleMessage consoleMessage) { + // Note: Decide what we are interested in logging and what level. super.onConsoleMessage(consoleMessage); final String methodTag = TAG + ":onConsoleMessage"; final String errorMessage = consoleMessage.message() + " -- From line " + @@ -535,6 +542,9 @@ private void setupPasskeyWebListener(@NonNull final WebView webView, // Downgrade to auth only mRequestHeaders.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_ONLY); } + //TEMPORARRY CHANGE + mRequestHeaders.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_ONLY); + } else { Logger.warn(methodTag, "Passkey protocol header not found or not for both auth and reg." + " Not hooking the PasskeyWebListener."); diff --git a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java index 2ca5a17981..3e316fa06f 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java @@ -88,13 +88,14 @@ import java.net.URISyntaxException; import java.net.URL; import java.security.Principal; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; import static com.microsoft.identity.common.adal.internal.AuthenticationConstants.Broker.AMAZON_APP_REDIRECT_PREFIX; import static com.microsoft.identity.common.adal.internal.AuthenticationConstants.Broker.AZURE_AUTHENTICATOR_APP_PACKAGE_NAME; @@ -140,12 +141,7 @@ public class AzureActiveDirectoryWebViewClient extends OAuth2WebViewClient { private final String mUtid; - private Consumer mRunOnPageStarted; - - // Method to assign the function - public void setRunOnPageStarted(@NonNull final Consumer fun) { - this.mRunOnPageStarted = fun; - } + private final List mOnPageStartedScripts = new ArrayList<>(); public AzureActiveDirectoryWebViewClient(@NonNull final Activity activity, @NonNull final IAuthorizationCompletionCallback completionCallback, @@ -1143,14 +1139,15 @@ public void onReceived(@Nullable final AbstractCertBasedAuthChallengeHandler cha }); } - @Override - public void onPageStarted(final WebView view, - final String url, - final Bitmap favicon) { + public void onPageStarted(final WebView view, final String url, final Bitmap favicon) { super.onPageStarted(view, url, favicon); - if(mRunOnPageStarted != null) { - mRunOnPageStarted.accept(url); + // Evaluate JavaScript for each script if URL matches allowed origins + for (final JsScriptRecord scriptRecord : mOnPageStartedScripts) { + if (scriptRecord.isAllowedForUrl(url)) { + Logger.info(TAG, "Executing onPageStarted script: " + scriptRecord.getId()); + view.evaluateJavascript(scriptRecord.getScript(), null); + } } } @@ -1232,4 +1229,20 @@ private Span createSpanWithAttributesFromParent(@NonNull final String spanName) } return span; } + + /** + * Add a JavaScript to be executed in onPageStarted. + * If allowedUrls is null, the script will be executed for all URLs. + * If allowedUrls is non-null, the script will be executed only for URLs that start with any of the allowed origins. + * @param script JavaScript code to be executed. + * @param allowedUrls Set of allowed URL origins. + */ + public void addOnPageStartedScript( + @NonNull final String scriptId, + @NonNull final String script, + @Nullable final Set allowedUrls) { + this.mOnPageStartedScripts.add( + new JsScriptRecord(scriptId, script, allowedUrls) + ); + } } diff --git a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/JsScriptRecord.kt b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/JsScriptRecord.kt new file mode 100644 index 0000000000..6a270ca6c8 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/JsScriptRecord.kt @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.internal.ui.webview + +/** + * Record representing a JavaScript script to be injected into a WebView, along with metadata + * about the script. + * + * @param id A unique identifier for the script. + * @param script The JavaScript code to be injected. + * @param allowedUrls An optional set of URL patterns where the script is allowed to be injected. + * If null, the script can be injected into any URL. If non-null, the script will only be injected + * into URLs that match one of the patterns in this set. + */ +class JsScriptRecord( + val id: String, + val script: String, + private val allowedUrls: Set? +) { + + /** + * Checks whether this script is allowed to execute for the given [url]. + * + * A script is considered allowed if: + * - [allowedUrls] is `null`, meaning no restrictions. + * - The provided [url] starts with any of the prefixes in [allowedUrls]. + * + * @param url The URL to check against the allowed list. + * @return `true` if the script can execute for this URL, `false` otherwise. + */ + fun isAllowedForUrl(url: String): Boolean { + // No restrictions — allowed for any URL + if (allowedUrls == null) return true + + // Check if the URL starts with any allowed prefix + return allowedUrls.any { prefix -> url.startsWith(prefix) } + } +} \ No newline at end of file diff --git a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java index 23a5eff2ac..f9f88c6f34 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java +++ b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java @@ -56,9 +56,9 @@ public enum CommonFlight implements IFlightConfig { /** * Flight to be able to disable/rollback the passkey feature in broker if necessary. - * This will be set to true by default. + * This will be set to false by default. */ - ENABLE_PASSKEY_FEATURE("EnablePasskeyFeature", true), + ENABLE_PASSKEY_FEATURE("EnablePasskeyFeature", false), /** * Flight to be able to disable/rollback the passkey feature in broker if necessary. From a6ec8734d5e62608e17657188e021914536504f1 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Fri, 10 Oct 2025 16:42:04 -0700 Subject: [PATCH 08/44] Refactor CredentialManagerHandler to improve passkey creation and retrieval logging; streamline credential request handling. --- .../oauth2/CredentialManagerHandler.kt | 37 +++++++++++++++---- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt index f1c9b78b22..55007f4af3 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt @@ -1,8 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. package com.microsoft.identity.common.internal.providers.oauth2 import android.app.Activity import android.os.Build -import android.util.Log import androidx.credentials.* import androidx.credentials.exceptions.* import com.microsoft.identity.common.logging.Logger @@ -27,10 +48,9 @@ class CredentialManagerHandler(private val activity: Activity) { Logger.info(methodTag, "Creating passkey with request: $request") val createRequest = CreatePublicKeyCredentialRequest(request) try { - Logger.info(methodTag, "Invoking CredentialManager.createCredential") - val response = mCredMan.createCredential(activity, createRequest) as CreatePublicKeyCredentialResponse - Logger.info(methodTag, "Passkey created successfully.") - return response + return (mCredMan.createCredential(activity, createRequest) as CreatePublicKeyCredentialResponse).also { + Logger.info(methodTag, "Passkey created successfully.") + } } catch (e: CreateCredentialException) { // For error handling use guidance from https://developer.android.com/training/sign-in/passkeys Logger.error(TAG, "Error creating credential: ErrMessage: ${e.errorMessage}, ErrType: ${e.type}", e) @@ -51,10 +71,11 @@ class CredentialManagerHandler(private val activity: Activity) { suspend fun getPasskey(request: String): GetCredentialResponse { val methodTag = "$TAG:getPasskey" Logger.info(methodTag, "Getting passkey with request: $request") - val getRequest = GetCredentialRequest(listOf(GetPublicKeyCredentialOption(request, null))) + val getRequest = GetCredentialRequest(listOf(GetPublicKeyCredentialOption(request))) try { - Logger.info(methodTag, "Invoking CredentialManager.getCredential") - return mCredMan.getCredential(activity, getRequest) + return mCredMan.getCredential(activity, getRequest).also { + Logger.info(methodTag, "Passkey retrieved successfully.") + } } catch (e: GetCredentialException) { // For error handling use guidance from https://developer.android.com/training/sign-in/passkeys Logger.error(TAG, "Error retrieving credential: ${e.message}", e) From 83b9c49b2f0bbae90ba3f6d56a2942bee781511d Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Fri, 10 Oct 2025 17:33:24 -0700 Subject: [PATCH 09/44] Refactor imports in CredentialManagerHandler for improved clarity and organization --- .../providers/oauth2/CredentialManagerHandler.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt index 55007f4af3..7a9d4aa037 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt @@ -24,8 +24,14 @@ package com.microsoft.identity.common.internal.providers.oauth2 import android.app.Activity import android.os.Build -import androidx.credentials.* -import androidx.credentials.exceptions.* +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 import com.microsoft.identity.common.logging.Logger class CredentialManagerHandler(private val activity: Activity) { From 578242c0b4c2708d665e13edc37f56dd227a175a Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 16 Oct 2025 12:46:01 -0700 Subject: [PATCH 10/44] Implement Passkey functionality with WebAuthn support - Added PasskeyReplyChannel for communication between JavaScript and native code. - Updated CredentialManagerHandler to create and retrieve passkeys. - Enhanced PasskeyWebListener to handle WebAuthn requests and responses. - Introduced js-bridge.js for JavaScript integration with WebAuthn. - Created unit tests for PasskeyReplyChannel to ensure correct message formatting and error handling. - Removed unnecessary logging statements and improved error handling. --- .../oauth2/CredentialManagerHandler.kt | 2 - .../providers/oauth2/PasskeyReplyChannel.kt | 103 +++++ .../providers/oauth2/PasskeyWebListener.kt | 386 +++++++++--------- .../internal/providers/oauth2/js-bridge.js | 186 +++++++++ .../oauth2/PasskeyReplyChannelTest.kt | 306 ++++++++++++++ 5 files changed, 797 insertions(+), 186 deletions(-) create mode 100644 common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt create mode 100644 common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/js-bridge.js create mode 100644 common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt index 7a9d4aa037..f50fd48ac8 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt @@ -51,7 +51,6 @@ class CredentialManagerHandler(private val activity: Activity) { suspend fun createPasskey(request: String): CreatePublicKeyCredentialResponse { val methodTag = "$TAG:createPasskey" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - Logger.info(methodTag, "Creating passkey with request: $request") val createRequest = CreatePublicKeyCredentialRequest(request) try { return (mCredMan.createCredential(activity, createRequest) as CreatePublicKeyCredentialResponse).also { @@ -76,7 +75,6 @@ class CredentialManagerHandler(private val activity: Activity) { */ suspend fun getPasskey(request: String): GetCredentialResponse { val methodTag = "$TAG:getPasskey" - Logger.info(methodTag, "Getting passkey with request: $request") val getRequest = GetCredentialRequest(listOf(GetPublicKeyCredentialOption(request))) try { return mCredMan.getCredential(activity, getRequest).also { diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt new file mode 100644 index 0000000000..27a54d7e29 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.internal.providers.oauth2 + +import androidx.webkit.JavaScriptReplyProxy +import com.microsoft.identity.common.logging.Logger +import org.json.JSONArray +import org.json.JSONObject + +/** + * A communication channel to post replies back to JavaScript code running in a WebView via a + * [JavaScriptReplyProxy]. + * + * This class provides methods to send success and error messages back to the JavaScript context. + * Messages are formatted as JSON arrays containing a status, data (or error message), and request type. + * + * @param replyProxy The [JavaScriptReplyProxy] used to send messages back to JavaScript. + * @param requestType An optional string indicating the type of request being handled. Defaults to "unknown". + */ +class PasskeyReplyChannel( + private val replyProxy: JavaScriptReplyProxy, + private val requestType: String = "unknown" +) { + companion object { + const val TAG = "PasskeyReplyChannel" + const val SUCCESS_STATUS = "success" + const val ERROR_STATUS = "error" + } + + + sealed class ReplyMessage { + abstract val type: String + class Success(val json: String, override val type: String) : ReplyMessage() + class Error(val errorMessage: String, override val type: String) : ReplyMessage() + + override fun toString(): String { + val (status, data, typeValue) = when (this) { + is Success -> { + val parsedData = runCatching { JSONObject(json) }.getOrElse { json } + Triple(SUCCESS_STATUS, parsedData, type) + } + is Error -> Triple(ERROR_STATUS, errorMessage, type) + } + return JSONArray(listOf(status, data, typeValue)).toString() + } + } + + + + fun postSuccess(json: String) { + val methodTag = "$TAG:postSuccess" + val message = ReplyMessage.Success(json, requestType) + send(message) + Logger.info(methodTag, "RequestType: $requestType, was successful.") + } + + fun postError(errorMessage: String) { + val methodTag = "$TAG:postError" + val message = ReplyMessage.Error(errorMessage, requestType) + send(message) + Logger.error(methodTag, "RequestType: $requestType, failed with error: $errorMessage", null) + + } + + fun postError(throwable: Throwable) { + val methodTag = "$TAG:postError" + val errorMessage = throwable.message ?: "Unknown error" + val message = ReplyMessage.Error(errorMessage , requestType) + send(message) + Logger.error(methodTag, "RequestType: $requestType, failed with error: $errorMessage", throwable) + } + + + //@SuppressLint("RequiresFeature", "OldTargetApi") + private fun send(message: ReplyMessage) { + val methodTag = "$TAG:send" + try { + replyProxy.postMessage(message.toString()) + }catch (t: Throwable) { + Logger.error(methodTag, "Reply message failed", t) + } + } +} diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt index 1b4aede70c..147b3ec387 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt @@ -1,13 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. package com.microsoft.identity.common.internal.providers.oauth2 import android.app.Activity import android.net.Uri +import android.os.Build 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 @@ -17,48 +37,35 @@ import com.microsoft.identity.common.logging.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import org.json.JSONArray import org.json.JSONObject +import java.util.concurrent.atomic.AtomicBoolean /** -This web listener looks for the 'postMessage()' call on the javascript web code, and when it -receives it, it will handle it in the manner dictated in this local codebase. This allows for -javascript on the web to interact with the local setup on device that contains more complex logic. - -The embedded javascript can be found in CredentialManagerWebView/javascript/encode.js. -It can be modified depending on the use case. If you wish to minify, please use the following command -to call the toptal minifier API. -``` -cat encode.js | grep -v '^let __webauthn_interface__;$' | \ -curl -X POST --data-urlencode input@- \ -https://www.toptal.com/developers/javascript-minifier/api/raw | tr '"' "'" | pbcopy -``` -pbpaste should output the proper minimized code. In linux, you may have to alias as follows: -``` -alias pbcopy='xclip -selection clipboard' -alias pbpaste='xclip -selection clipboard -o' -``` -in your bashrc. + * WebView message listener for handling WebAuthn/Passkey authentication flows. + * + * Intercepts postMessage() calls from JavaScript to handle credential creation and retrieval + * using the Android Credential Manager API. Only accepts requests from allowed origins. + * + * @property coroutineScope Scope for launching credential operations. + * @property credentialManagerHandler Handles passkey creation and retrieval. */ 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 cancelled, 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 + /** Tracks if a WebAuthn request is currently pending. Only one request is allowed at a time. */ + private val havePendingRequest = AtomicBoolean(false) - /** Called by the page when it wants to do a WebAuthn `get` or 'post' request. */ + /** + * Handles postMessage() calls from the web page for WebAuthn requests. + * + * @param view The WebView that received the message. + * @param message The message received from the web page. + * @param sourceOrigin The origin of the message. + * @param isMainFrame True if the message originated from the main frame. + * @param replyProxy Proxy for sending responses back to JavaScript. + */ @UiThread override fun onPostMessage( view: WebView, @@ -67,219 +74,230 @@ class PasskeyWebListener( isMainFrame: Boolean, replyProxy: JavaScriptReplyProxy, ) { - Logger.info(TAG, "In Post Message : $message source: $sourceOrigin") - val messageData = message.data ?: return - onRequest(messageData, sourceOrigin, isMainFrame, JavaScriptReplyChannel(replyProxy)) + parseMessage(message.data, replyProxy)?.let { webAuthnMessage -> + onRequest( + webAuthnMessage = webAuthnMessage, + sourceOrigin = sourceOrigin, + isMainFrame = isMainFrame, + javaScriptReplyProxy = replyProxy + ) + } } - + /** + * Processes an incoming WebAuthn request. + * + * @param webAuthnMessage Parsed WebAuthn message. + * @param sourceOrigin Origin of the request. + * @param isMainFrame True if request is from the main frame. + * @param javaScriptReplyProxy Proxy for sending responses. + */ private fun onRequest( - msg: String, + webAuthnMessage: WebAuthnMessage, sourceOrigin: Uri, isMainFrame: Boolean, - reply: ReplyChannel, - ) { - msg.let { - val jsonObj = JSONObject(msg) - val type = jsonObj.getString(TYPE_KEY) - val message = jsonObj.getString(REQUEST_KEY) + javaScriptReplyProxy: JavaScriptReplyProxy) { + val methodTag = "$TAG:onRequest" + Logger.info(methodTag, "Received WebAuthn request of type: ${webAuthnMessage.type} from origin: $sourceOrigin") + val passkeyReplyChannel = PasskeyReplyChannel(javaScriptReplyProxy, webAuthnMessage.type) - if (havePendingRequest) { - postErrorMessage(reply, "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 - } - - // Only allow requests from known origins. - if (sourceOrigin.toString() != "https://login.microsoft.com") { - // TODO: Add all other known supported origins here (e.g., UsGov and other clouds) - reportFailure("WebAuthN not permitted for origin: ${sourceOrigin}", type) - return - } - - havePendingRequest = true - pendingRequestIsDoomed = false + // Only allow one request at a time. + if (havePendingRequest.get()) { + passkeyReplyChannel.postError("Request already in progress") + return + } + havePendingRequest.set(true) - // Let’s 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) { - Logger.info(TAG, "reply channel was null, cannot continue") - return - } + // Only allow requests from the main frame. + if (!isMainFrame) { + passkeyReplyChannel.postError("Requests from iframes are not supported") + havePendingRequest.set(false) + return + } - when (type) { - CREATE_UNIQUE_KEY -> - this.coroutineScope.launch { - handleCreateFlow(credentialManagerHandler, message, replyCurrent) - } - GET_UNIQUE_KEY -> this.coroutineScope.launch { - handleGetFlow(credentialManagerHandler, message, replyCurrent) + when (webAuthnMessage.type) { + CREATE_UNIQUE_KEY -> + this.coroutineScope.launch { + handleCreateFlow(credentialManagerHandler, webAuthnMessage.request, passkeyReplyChannel) + havePendingRequest.set(false) } - else -> Logger.info(TAG, "Incorrect request json") + GET_UNIQUE_KEY -> this.coroutineScope.launch { + handleGetFlow(credentialManagerHandler, webAuthnMessage.request, passkeyReplyChannel) + havePendingRequest.set(false) + } + else -> { + passkeyReplyChannel.postError("Unknown request type: ${webAuthnMessage.type}") + havePendingRequest.set(false) } } } - // Handles the get flow in a less error-prone way + /** + * Handles the WebAuthn get flow to retrieve an existing passkey. + * + * @param credentialManagerHandler Handler for credential operations. + * @param message JSON string with the get request parameters. + * @param reply Channel for sending the response. + */ private suspend fun handleGetFlow( credentialManagerHandler: CredentialManagerHandler, message: String, - reply: ReplyChannel, - ) { + reply: PasskeyReplyChannel) { 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) + val getCredentialResponse = credentialManagerHandler.getPasskey(message) + reply.postSuccess( + (getCredentialResponse.credential as PublicKeyCredential).authenticationResponseJson + ) } catch (t: Throwable) { - reportFailure("Error: ${t.message}", GET_UNIQUE_KEY) + reply.postError(t) } } - // handles the create flow in a less error prone way + /** + * Handles the WebAuthn create flow to register a new passkey. + * + * @param credentialManagerHandler Handler for credential operations. + * @param message JSON string with the create request parameters. + * @param reply Channel for sending the response. + */ private suspend fun handleCreateFlow( credentialManagerHandler: CredentialManagerHandler, message: String, - reply: ReplyChannel, - ) { + reply: PasskeyReplyChannel) { 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()) - Logger.info(TAG, successArray.joinToString { it.toString() }) - replyChannel = null // setting initial replyChannel for next request given temp 'reply' - } catch (e: CreateCredentialException) { - reportFailure("Error: ${e.errorMessage} w type: ${e.type} w obj: $e", - CREATE_UNIQUE_KEY) + val createCredentialResponse = credentialManagerHandler.createPasskey(message) + reply.postSuccess(createCredentialResponse.registrationResponseJson) } catch (t: Throwable) { - reportFailure("Error: ${t.message}", CREATE_UNIQUE_KEY) + reply.postError(t) } } - /** Invalidates any current request. */ - fun onPageStarted() { - if (havePendingRequest) { - pendingRequestIsDoomed = true - } - } + /** + * Parses a JSON message into a [WebAuthnMessage]. + * + * Expected format: `{"type": "create|get", "request": ""}` + * + * @param messageData JSON string to parse. + * @param javaScriptReplyProxy Proxy for error responses. + * @return Parsed [WebAuthnMessage] or null if invalid. + */ + private fun parseMessage(messageData: String?, javaScriptReplyProxy: JavaScriptReplyProxy): WebAuthnMessage? { - /** 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) - } + val passkeyReplyChannel = PasskeyReplyChannel(javaScriptReplyProxy) + if (messageData.isNullOrBlank()) { + passkeyReplyChannel.postError("Received empty message data") + return null + } - private fun postErrorMessage(reply: ReplyChannel, errorMessage: String, type: String) { - Logger.info(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() - } + return runCatching { + val json = JSONObject(messageData) + val type = json.optString(TYPE_KEY).takeIf { it.isNotBlank() } + val request = json.optString(REQUEST_KEY).takeIf { it.isNotBlank() } - private class JavaScriptReplyChannel(private val reply: JavaScriptReplyProxy) : - ReplyChannel { - override fun send(message: String?) { - try { - reply.postMessage(message!!) - }catch (t: Throwable) { - Logger.info(TAG, "Reply failure due to: " + t.message); + if (type == null ) { + passkeyReplyChannel.postError("Missing required key: type") + null + } else if (request == null) { + passkeyReplyChannel.postError("Missing required key: request") + null + } else { + WebAuthnMessage(type, request) } - } + }.onFailure { throwable -> + passkeyReplyChannel.postError(throwable) + }.getOrNull() } - /** ReplyChannel is the interface over which replies to the embedded site are sent. This allows - for testing because AndroidX bans mocking its objects.*/ - interface ReplyChannel { - fun send(message: String?) - } + /** Internal representation of a WebAuthn message with type and request payload. */ + private data class WebAuthnMessage(val type: String, val request: String) companion object { - /** INTERFACE_NAME is the name of the MessagePort that must be injected into pages. */ - private const val INTERFACE_NAME = "__webauthn_interface__" + const val TAG = "PasskeyWebListener" + /** WebAuthn request type for creating a new credential. */ const val CREATE_UNIQUE_KEY = "create" + + /** WebAuthn request type for retrieving an existing credential. */ const val GET_UNIQUE_KEY = "get" + + /** JSON key for the request type field. */ const val TYPE_KEY = "type" + + /** JSON key for the request payload field. */ const val REQUEST_KEY = "request" - /** 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 WEB_AUTHN_INTERFACE_JS_MINIFIED = """ + /** Name of the JavaScript message port interface. */ + private const val INTERFACE_NAME = "__webauthn_interface__" + + /** + * Minified JavaScript code that intercepts WebAuthn API calls. + * Source: credmanweb/javascript/encode.js + */ + private const val WEB_AUTHN_INTERFACE_JS_MINIFIED = """ var __webauthn_interface__,__webauthn_hooks__;!function(e){__webauthn_interface__.addEventListener("message",function e(n){console.log(n.data);var r=JSON.parse(n.data),t=r[2];"get"===t?s(r):"create"===t?u(r):console.log("Incorrect response format for reply")});var n=null,r=null,t=null,a=null;function s(e){if(null===n||null===t){console.log("Reply failure: Resolve: "+r+" and reject: "+a);return}if("success"!=e[0]){var s=t;n=null,t=null,s(new DOMException(e[1],"NotAllowedError"));return}var l=i(e[1]),o=n;n=null,t=null,o(l)}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 o(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("Reply failure: Resolve: "+r+" and reject: "+a);return}if("success"!=e[0]){var n=a;r=null,a=null,n(new DOMException(e[1],"NotAllowedError"));return}var t=i(e[1]),s=r;r=null,a=null,s(t)}function i(e){return 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.response.getTransports=function n(){return e.response.hasOwnProperty("transports")?e.response.transports:[]},e}e.create=function n(t){if(!("publicKey"in t))return e.originalCreateFunction(t);var s=new Promise(function(e,n){r=e,a=n}),l=t.publicKey;if(l.hasOwnProperty("challenge")){var u=o(l.challenge);l.challenge=u}if(l.hasOwnProperty("user")&&l.user.hasOwnProperty("id")){var i=o(l.user.id);l.user.id=i}if(l.hasOwnProperty("excludeCredentials")&&Array.isArray(l.excludeCredentials)&&l.excludeCredentials.length>0)for(var c=0;c 0) { + for (var i = 0; i < temppk.excludeCredentials.length; i++) { + var cred = temppk.excludeCredentials[i]; + if (cred && cred.hasOwnProperty('id')) { + cred.id = CM_base64url_encode(cred.id); + } + } + } + var jsonObj = {"type":"create", "request":temppk} + + var json = JSON.stringify(jsonObj); + __webauthn_interface__.postMessage(json); + return ret; + } + __webauthn_hooks__.create = create; + // get overrides `navigator.credentials.get` and proxies any WebAuthn + // requests to the get embedder. + function get(request) { + if (!("publicKey" in request)) { + return __webauthn_hooks__.originalGetFunction(request); + } + var ret = new Promise(function (resolve, reject) { + pendingResolveGet = resolve; + pendingRejectGet = reject; + }); + var temppk = request.publicKey; + if (temppk.hasOwnProperty('challenge')) { + var str = CM_base64url_encode(temppk.challenge); + temppk.challenge = str; + } + var jsonObj = {"type":"get", "request":temppk} + + var json = JSON.stringify(jsonObj); + __webauthn_interface__.postMessage(json); + return ret; + } + __webauthn_hooks__.get = get; + + // The embedder gives replies back here, caught by the event listener. + function onReply(msg) { + console.log(msg.data); + var reply = JSON.parse(msg.data); + var type = reply[2]; + if(type === "get") { + onReplyGet(reply); + } else if (type === "create") { + onReplyCreate(reply); + } else { + console.log("Incorrect response format for reply"); + } + } + + // Resolves what is expected for get, called when the embedder is ready + function onReplyGet(reply) { + if (pendingResolveGet === null || pendingRejectGet === null) { + console.log("Reply failure: Resolve: " + pendingResolveCreate + + " and reject: " + pendingRejectCreate); + return; + } + if (reply[0] != 'success') { + var reject = pendingRejectGet; + pendingResolveGet = null; + pendingRejectGet = null; + reject(new DOMException(reply[1], "NotAllowedError")); + return; + } + var cred = credentialManagerDecode(reply[1]); + var resolve = pendingResolveGet; + pendingResolveGet = null; + pendingRejectGet = null; + resolve(cred); + } + __webauthn_hooks__.onReplyGet = onReplyGet; + // This a specific decoder for expected types contained in PublicKeyCredential json + function CM_base64url_decode(value) { + var m = value.length % 4; + return Uint8Array.from(atob(value.replace(/-/g, '+') + .replace(/_/g, '/') + .padEnd(value.length + (m === 0 ? 0 : 4 - m), '=')), function (c) + { return c.charCodeAt(0); }).buffer; + } + __webauthn_hooks__.CM_base64url_decode = CM_base64url_decode; + function CM_base64url_encode(buffer) { + return btoa(Array.from(new Uint8Array(buffer), function (b) + { return String.fromCharCode(b); }).join('')) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+${'$'}/, ''); + } + __webauthn_hooks__.CM_base64url_encode = CM_base64url_encode; + // Resolves what is expected for create, called when the embedder is ready + function onReplyCreate(reply) { + if (pendingResolveCreate === null || pendingRejectCreate === null) { + console.log("Reply failure: Resolve: " + pendingResolveCreate + + " and reject: " + pendingRejectCreate); + return; + } + + if (reply[0] != 'success') { + var reject = pendingRejectCreate; + pendingResolveCreate = null; + pendingRejectCreate = null; + reject(new DOMException(reply[1], "NotAllowedError")); + return; + } + var cred = credentialManagerDecode(reply[1]); + var resolve = pendingResolveCreate; + pendingResolveCreate = null; + pendingRejectCreate = null; + resolve(cred); + } + __webauthn_hooks__.onReplyCreate = onReplyCreate; + /** + * This decodes the output from the credential manager flow to parse back into URL format. Both + * get and create flows ultimately return a PublicKeyCredential object. + * @param json_result + */ + function credentialManagerDecode(decoded_reply) { + decoded_reply.rawId = CM_base64url_decode(decoded_reply.rawId); + decoded_reply.response.clientDataJSON = CM_base64url_decode(decoded_reply.response.clientDataJSON); + if (decoded_reply.response.hasOwnProperty('attestationObject')) { + decoded_reply.response.attestationObject = CM_base64url_decode(decoded_reply.response.attestationObject); + } + if (decoded_reply.response.hasOwnProperty('authenticatorData')) { + decoded_reply.response.authenticatorData = CM_base64url_decode(decoded_reply.response.authenticatorData); + } + if (decoded_reply.response.hasOwnProperty('signature')) { + decoded_reply.response.signature = CM_base64url_decode(decoded_reply.response.signature); + } + if (decoded_reply.response.hasOwnProperty('userHandle')) { + decoded_reply.response.userHandle = CM_base64url_decode(decoded_reply.response.userHandle); + } + decoded_reply.getClientExtensionResults = function getClientExtensionResults() { return {}; }; + decoded_reply.response.getTransports = function getTransports() { + if (decoded_reply.response.hasOwnProperty('transports')) { return decoded_reply.response.transports; } + return []; + }; + return decoded_reply; + } +})(__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; +// Some sites test that `typeof window.PublicKeyCredential` is +// `function`. +window.PublicKeyCredential = (function () { }); +window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable = + function () { + return Promise.resolve(false); + }; diff --git a/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt b/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt new file mode 100644 index 0000000000..6eabf3cddd --- /dev/null +++ b/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt @@ -0,0 +1,306 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +package com.microsoft.identity.common.internal.providers.oauth2 + +import androidx.webkit.JavaScriptReplyProxy +import io.mockk.mockk +import io.mockk.verify +import io.mockk.every +import io.mockk.slot +import org.json.JSONArray +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import java.lang.RuntimeException + +class PasskeyReplyChannelTest { + + private lateinit var mockReplyProxy: JavaScriptReplyProxy + private lateinit var passkeyReplyChannel: PasskeyReplyChannel + private val testRequestType = "test_request_type" + + @Before + fun setUp() { + mockReplyProxy = mockk(relaxed = true) + passkeyReplyChannel = PasskeyReplyChannel(mockReplyProxy, testRequestType) + } + + @Test + fun `postSuccess sends correct success message format`() { + // Given + val testJson = """{"key": "value"}""" + val messageSlot = slot() + + // When + passkeyReplyChannel.postSuccess(testJson) + + // Then + verify { mockReplyProxy.postMessage(capture(messageSlot)) } + + val capturedMessage = messageSlot.captured + val messageArray = JSONArray(capturedMessage) + + assertEquals(3, messageArray.length()) + assertEquals(PasskeyReplyChannel.SUCCESS_STATUS, messageArray.getString(0)) + assertEquals(testRequestType, messageArray.getString(2)) + + // Verify the JSON object is parsed correctly + val dataObject = messageArray.getJSONObject(1) + assertEquals("value", dataObject.getString("key")) + } + + @Test + fun `postSuccess handles invalid JSON gracefully`() { + // Given + val invalidJson = "invalid json string" + val messageSlot = slot() + + // When + passkeyReplyChannel.postSuccess(invalidJson) + + // Then + verify { mockReplyProxy.postMessage(capture(messageSlot)) } + + val capturedMessage = messageSlot.captured + val messageArray = JSONArray(capturedMessage) + + assertEquals(3, messageArray.length()) + assertEquals(PasskeyReplyChannel.SUCCESS_STATUS, messageArray.getString(0)) + assertEquals(invalidJson, messageArray.getString(1)) // Should use raw string when JSON parsing fails + assertEquals(testRequestType, messageArray.getString(2)) + } + + @Test + fun `postError with string message sends correct error format`() { + // Given + val errorMessage = "Test error message" + val messageSlot = slot() + + // When + passkeyReplyChannel.postError(errorMessage) + + // Then + verify { mockReplyProxy.postMessage(capture(messageSlot)) } + + val capturedMessage = messageSlot.captured + val messageArray = JSONArray(capturedMessage) + + assertEquals(3, messageArray.length()) + assertEquals(PasskeyReplyChannel.ERROR_STATUS, messageArray.getString(0)) + assertEquals(errorMessage, messageArray.getString(1)) + assertEquals(testRequestType, messageArray.getString(2)) + } + + @Test + fun `postError with throwable sends correct error format`() { + // Given + val exceptionMessage = "Exception occurred" + val testException = RuntimeException(exceptionMessage) + val messageSlot = slot() + + // When + passkeyReplyChannel.postError(testException) + + // Then + verify { mockReplyProxy.postMessage(capture(messageSlot)) } + + val capturedMessage = messageSlot.captured + val messageArray = JSONArray(capturedMessage) + + assertEquals(3, messageArray.length()) + assertEquals(PasskeyReplyChannel.ERROR_STATUS, messageArray.getString(0)) + assertEquals(exceptionMessage, messageArray.getString(1)) + assertEquals(testRequestType, messageArray.getString(2)) + } + + @Test + fun `postError with throwable handles null message`() { + // Given + val testException = RuntimeException(null as String?) + val messageSlot = slot() + + // When + passkeyReplyChannel.postError(testException) + + // Then + verify { mockReplyProxy.postMessage(capture(messageSlot)) } + + val capturedMessage = messageSlot.captured + val messageArray = JSONArray(capturedMessage) + + assertEquals(3, messageArray.length()) + assertEquals(PasskeyReplyChannel.ERROR_STATUS, messageArray.getString(0)) + assertEquals("Unknown error", messageArray.getString(1)) + assertEquals(testRequestType, messageArray.getString(2)) + } + + @Test + fun `send method handles postMessage exceptions gracefully`() { + // Given + val testJson = """{"key": "value"}""" + every { mockReplyProxy.postMessage(any()) } throws RuntimeException("PostMessage failed") + + // When/Then - Should not throw exception + assertDoesNotThrow { + passkeyReplyChannel.postSuccess(testJson) + } + + verify { mockReplyProxy.postMessage(any()) } + } + + @Test + fun `constructor with default request type uses unknown`() { + // Given + val channelWithDefaultType = PasskeyReplyChannel(mockReplyProxy) + val messageSlot = slot() + + // When + channelWithDefaultType.postSuccess("""{"test": "data"}""") + + // Then + verify { mockReplyProxy.postMessage(capture(messageSlot)) } + + val capturedMessage = messageSlot.captured + val messageArray = JSONArray(capturedMessage) + assertEquals("unknown", messageArray.getString(2)) + } + + @Test + fun `ReplyMessage Success toString creates valid JSON array`() { + // Given + val testJson = """{"key": "value"}""" + val successMessage = PasskeyReplyChannel.ReplyMessage.Success(testJson, testRequestType) + + // When + val result = successMessage.toString() + + // Then + val messageArray = JSONArray(result) + assertEquals(3, messageArray.length()) + assertEquals(PasskeyReplyChannel.SUCCESS_STATUS, messageArray.getString(0)) + assertEquals(testRequestType, messageArray.getString(2)) + + val dataObject = messageArray.getJSONObject(1) + assertEquals("value", dataObject.getString("key")) + } + + @Test + fun `ReplyMessage Error toString creates valid JSON array`() { + // Given + val errorMessage = "Test error" + val errorReplyMessage = PasskeyReplyChannel.ReplyMessage.Error(errorMessage, testRequestType) + + // When + val result = errorReplyMessage.toString() + + // Then + val messageArray = JSONArray(result) + assertEquals(3, messageArray.length()) + assertEquals(PasskeyReplyChannel.ERROR_STATUS, messageArray.getString(0)) + assertEquals(errorMessage, messageArray.getString(1)) + assertEquals(testRequestType, messageArray.getString(2)) + } + + @Test + fun `ReplyMessage Success handles malformed JSON in toString`() { + // Given + val malformedJson = "not a valid json" + val successMessage = PasskeyReplyChannel.ReplyMessage.Success(malformedJson, testRequestType) + + // When + val result = successMessage.toString() + + // Then + assertDoesNotThrow { + val messageArray = JSONArray(result) + assertEquals(3, messageArray.length()) + assertEquals(PasskeyReplyChannel.SUCCESS_STATUS, messageArray.getString(0)) + assertEquals(malformedJson, messageArray.getString(1)) // Should use raw string + assertEquals(testRequestType, messageArray.getString(2)) + } + } + + @Test + fun `constants have correct values`() { + assertEquals("success", PasskeyReplyChannel.SUCCESS_STATUS) + assertEquals("error", PasskeyReplyChannel.ERROR_STATUS) + assertEquals("PasskeyReplyChannel", PasskeyReplyChannel.TAG) + } + + @Test + fun `ReplyMessage Success handles empty JSON object`() { + // Given + val emptyJson = "{}" + val successMessage = PasskeyReplyChannel.ReplyMessage.Success(emptyJson, testRequestType) + + // When + val result = successMessage.toString() + + // Then + val messageArray = JSONArray(result) + assertEquals(3, messageArray.length()) + assertEquals(PasskeyReplyChannel.SUCCESS_STATUS, messageArray.getString(0)) + + val dataObject = messageArray.getJSONObject(1) + assertEquals(0, dataObject.length()) // Empty JSON object + assertEquals(testRequestType, messageArray.getString(2)) + } + + @Test + fun `ReplyMessage Success handles complex JSON structures`() { + // Given + val complexJson = """ + { + "nested": { + "array": [1, 2, 3], + "boolean": true, + "null": null + }, + "string": "test" + } + """.trimIndent() + val successMessage = PasskeyReplyChannel.ReplyMessage.Success(complexJson, testRequestType) + + // When + val result = successMessage.toString() + + // Then + val messageArray = JSONArray(result) + assertEquals(3, messageArray.length()) + assertEquals(PasskeyReplyChannel.SUCCESS_STATUS, messageArray.getString(0)) + + val dataObject = messageArray.getJSONObject(1) + assertEquals("test", dataObject.getString("string")) + assertEquals(true, dataObject.getJSONObject("nested").getBoolean("boolean")) + assertEquals(testRequestType, messageArray.getString(2)) + } + + private fun assertDoesNotThrow(executable: () -> Unit) { + try { + executable() + } catch (e: Exception) { + fail("Expected no exception, but got: ${e.message}") + } + } +} From 5c8bdbb705c68a76be4253f05fbeed7f42f4cc5a Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Fri, 17 Oct 2025 13:19:30 -0700 Subject: [PATCH 11/44] Refactor PasskeyReplyChannel and related components for improved error handling and message formatting; enhance test coverage for success and error scenarios. --- .../providers/oauth2/PasskeyReplyChannel.kt | 185 +++++++++++--- .../providers/oauth2/PasskeyWebListener.kt | 2 +- .../internal/providers/oauth2/js-bridge.js | 19 +- .../oauth2/PasskeyReplyChannelTest.kt | 231 +++++++++--------- 4 files changed, 275 insertions(+), 162 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt index 27a54d7e29..8b74cb5270 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt @@ -22,20 +22,28 @@ // THE SOFTWARE. package com.microsoft.identity.common.internal.providers.oauth2 +import android.annotation.SuppressLint +import androidx.credentials.exceptions.CreateCredentialCancellationException +import androidx.credentials.exceptions.CreateCredentialInterruptedException +import androidx.credentials.exceptions.CreateCredentialProviderConfigurationException +import androidx.credentials.exceptions.CreateCredentialUnknownException +import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.GetCredentialInterruptedException +import androidx.credentials.exceptions.GetCredentialProviderConfigurationException +import androidx.credentials.exceptions.GetCredentialUnknownException +import androidx.credentials.exceptions.NoCredentialException import androidx.webkit.JavaScriptReplyProxy import com.microsoft.identity.common.logging.Logger -import org.json.JSONArray import org.json.JSONObject + /** - * A communication channel to post replies back to JavaScript code running in a WebView via a - * [JavaScriptReplyProxy]. + * Communication channel for sending WebAuthn responses back to JavaScript via [JavaScriptReplyProxy]. * - * This class provides methods to send success and error messages back to the JavaScript context. - * Messages are formatted as JSON arrays containing a status, data (or error message), and request type. + * Formats messages as JSON containing status, data, and request type for WebAuthn credential operations. * - * @param replyProxy The [JavaScriptReplyProxy] used to send messages back to JavaScript. - * @param requestType An optional string indicating the type of request being handled. Defaults to "unknown". + * @property replyProxy Proxy for sending messages to JavaScript. + * @property requestType Type of WebAuthn request (e.g., "create", "get"). Defaults to "unknown". */ class PasskeyReplyChannel( private val replyProxy: JavaScriptReplyProxy, @@ -43,55 +51,168 @@ class PasskeyReplyChannel( ) { companion object { const val TAG = "PasskeyReplyChannel" + + // JSON message structure keys + const val STATUS_KEY = "status" + const val DATA_KEY = "data" + const val TYPE_KEY = "type" + + // DOMException error details keys + const val DOM_EXCEPTION_MESSAGE_KEY = "domExceptionMessage" + const val DOM_EXCEPTION_NAME_KEY = "domExceptionName" + + // Message status values const val SUCCESS_STATUS = "success" const val ERROR_STATUS = "error" - } + // DOMException names per W3C WebAuthn specification + const val DOM_EXCEPTION_NOT_ALLOWED_ERROR = "NotAllowedError" + const val DOM_EXCEPTION_ABORT_ERROR = "AbortError" + const val DOM_EXCEPTION_NOT_SUPPORTED_ERROR = "NotSupportedError" + const val DOM_EXCEPTION_UNKNOWN_ERROR = "UnknownError" + + } + /** + * Sealed class representing messages sent to JavaScript. + */ sealed class ReplyMessage { abstract val type: String - class Success(val json: String, override val type: String) : ReplyMessage() - class Error(val errorMessage: String, override val type: String) : ReplyMessage() + abstract val status: String + abstract val data: JSONObject - override fun toString(): String { - val (status, data, typeValue) = when (this) { - is Success -> { - val parsedData = runCatching { JSONObject(json) }.getOrElse { json } - Triple(SUCCESS_STATUS, parsedData, type) - } - is Error -> Triple(ERROR_STATUS, errorMessage, type) - } - return JSONArray(listOf(status, data, typeValue)).toString() + /** + * Success message containing credential data. + * + * @property json JSON string with credential response data. + * @property type Request type that succeeded. + */ + class Success(val json: String, override val type: String) : ReplyMessage() { + override val status = SUCCESS_STATUS + override val data: JSONObject = runCatching { JSONObject(json) }.getOrElse { JSONObject() } } - } + /** + * Error message with DOMException details. + * + * @property domExceptionMessage Error description. + * @property domExceptionName DOMException name per W3C spec. + * @property type Request type that failed. + */ + class Error( + private val domExceptionMessage: String, + private val domExceptionName: String = DOM_EXCEPTION_NOT_ALLOWED_ERROR, + override val type: String) : ReplyMessage(){ + override val status = ERROR_STATUS + override val data: JSONObject + get() { + return JSONObject().apply { + put(DOM_EXCEPTION_MESSAGE_KEY, domExceptionMessage) + put(DOM_EXCEPTION_NAME_KEY, domExceptionName) + } + } + } + /** Serializes the message to JSON string. */ + override fun toString(): String { + return JSONObject().apply { + put(STATUS_KEY, status) + put(DATA_KEY, data) + put(TYPE_KEY, type) + }.toString() + } + } + /** + * Posts a success message with credential data. + * + * @param json JSON string containing the credential response. + */ fun postSuccess(json: String) { val methodTag = "$TAG:postSuccess" - val message = ReplyMessage.Success(json, requestType) - send(message) + send(ReplyMessage.Success(json, requestType)) Logger.info(methodTag, "RequestType: $requestType, was successful.") } + /** + * Posts an error message with a custom error description. + * + * @param errorMessage Error description to send. + */ fun postError(errorMessage: String) { - val methodTag = "$TAG:postError" - val message = ReplyMessage.Error(errorMessage, requestType) - send(message) - Logger.error(methodTag, "RequestType: $requestType, failed with error: $errorMessage", null) - + postErrorInternal( + ReplyMessage.Error(domExceptionMessage = errorMessage, type = requestType) + ) } + /** + * Posts an error message based on a thrown exception. + * + * Maps credential exceptions to appropriate DOMException types. + * + * @param throwable Exception to convert and send. + */ fun postError(throwable: Throwable) { + postErrorInternal(throwableToErrorMessage(throwable)) + } + + /** + * Internal method to send error messages and log them. + */ + private fun postErrorInternal(errorMessage: ReplyMessage.Error) { val methodTag = "$TAG:postError" - val errorMessage = throwable.message ?: "Unknown error" - val message = ReplyMessage.Error(errorMessage , requestType) - send(message) - Logger.error(methodTag, "RequestType: $requestType, failed with error: $errorMessage", throwable) + send(errorMessage) + Logger.error(methodTag, "RequestType: $requestType, failed with error: $errorMessage", null) } + /** + * Maps credential exceptions to DOMException error messages. + * + * Conversion table: + * - Cancellation/No credential → NotAllowedError + * - Interruption → AbortError + * - Configuration → NotSupportedError + * - Unknown → UnknownError + * + * @param throwable Exception to map. + * @return Error message with appropriate DOMException details. + */ + private fun throwableToErrorMessage(throwable: Throwable): ReplyMessage.Error { + val errorMessage = throwable.message ?: "Unknown error (empty message)" + + val exceptionName = when (throwable) { + // Cancellation exceptions - User cancelled + is CreateCredentialCancellationException, + is GetCredentialCancellationException, + is NoCredentialException -> DOM_EXCEPTION_NOT_ALLOWED_ERROR + + // Interruption exceptions - Operation aborted + is CreateCredentialInterruptedException, + is GetCredentialInterruptedException -> DOM_EXCEPTION_ABORT_ERROR + + // Provider configuration exceptions - Not supported + is CreateCredentialProviderConfigurationException, + is GetCredentialProviderConfigurationException -> DOM_EXCEPTION_NOT_SUPPORTED_ERROR + + // Unknown exceptions + is CreateCredentialUnknownException, + is GetCredentialUnknownException -> DOM_EXCEPTION_UNKNOWN_ERROR + + // Default case for other exceptions + else -> DOM_EXCEPTION_NOT_ALLOWED_ERROR + } + + return ReplyMessage.Error( + domExceptionMessage = errorMessage, + domExceptionName = exceptionName, + type = requestType + ) + } - //@SuppressLint("RequiresFeature", "OldTargetApi") + /** + * Sends a message to JavaScript via the reply proxy. + */ + @SuppressLint("RequiresFeature", "Only called when feature is available") private fun send(message: ReplyMessage) { val methodTag = "$TAG:send" try { diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt index 147b3ec387..59db04a8e3 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt @@ -234,7 +234,7 @@ class PasskeyWebListener( * Source: credmanweb/javascript/encode.js */ private const val WEB_AUTHN_INTERFACE_JS_MINIFIED = """ - var __webauthn_interface__,__webauthn_hooks__;!function(e){__webauthn_interface__.addEventListener("message",function e(n){console.log(n.data);var r=JSON.parse(n.data),t=r[2];"get"===t?s(r):"create"===t?u(r):console.log("Incorrect response format for reply")});var n=null,r=null,t=null,a=null;function s(e){if(null===n||null===t){console.log("Reply failure: Resolve: "+r+" and reject: "+a);return}if("success"!=e[0]){var s=t;n=null,t=null,s(new DOMException(e[1],"NotAllowedError"));return}var l=i(e[1]),o=n;n=null,t=null,o(l)}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 o(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("Reply failure: Resolve: "+r+" and reject: "+a);return}if("success"!=e[0]){var n=a;r=null,a=null,n(new DOMException(e[1],"NotAllowedError"));return}var t=i(e[1]),s=r;r=null,a=null,s(t)}function i(e){return 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.response.getTransports=function n(){return e.response.hasOwnProperty("transports")?e.response.transports:[]},e}e.create=function n(t){if(!("publicKey"in t))return e.originalCreateFunction(t);var s=new Promise(function(e,n){r=e,a=n}),l=t.publicKey;if(l.hasOwnProperty("challenge")){var u=o(l.challenge);l.challenge=u}if(l.hasOwnProperty("user")&&l.user.hasOwnProperty("id")){var i=o(l.user.id);l.user.id=i}if(l.hasOwnProperty("excludeCredentials")&&Array.isArray(l.excludeCredentials)&&l.excludeCredentials.length>0)for(var c=0;c0)for(var u=0;u() @@ -58,15 +62,11 @@ class PasskeyReplyChannelTest { // Then verify { mockReplyProxy.postMessage(capture(messageSlot)) } - val capturedMessage = messageSlot.captured - val messageArray = JSONArray(capturedMessage) + val messageObject = JSONObject(messageSlot.captured) + assertEquals(PasskeyReplyChannel.SUCCESS_STATUS, messageObject.getString(PasskeyReplyChannel.STATUS_KEY)) + assertEquals(testRequestType, messageObject.getString(PasskeyReplyChannel.TYPE_KEY)) - assertEquals(3, messageArray.length()) - assertEquals(PasskeyReplyChannel.SUCCESS_STATUS, messageArray.getString(0)) - assertEquals(testRequestType, messageArray.getString(2)) - - // Verify the JSON object is parsed correctly - val dataObject = messageArray.getJSONObject(1) + val dataObject = messageObject.getJSONObject(PasskeyReplyChannel.DATA_KEY) assertEquals("value", dataObject.getString("key")) } @@ -82,17 +82,13 @@ class PasskeyReplyChannelTest { // Then verify { mockReplyProxy.postMessage(capture(messageSlot)) } - val capturedMessage = messageSlot.captured - val messageArray = JSONArray(capturedMessage) - - assertEquals(3, messageArray.length()) - assertEquals(PasskeyReplyChannel.SUCCESS_STATUS, messageArray.getString(0)) - assertEquals(invalidJson, messageArray.getString(1)) // Should use raw string when JSON parsing fails - assertEquals(testRequestType, messageArray.getString(2)) + val messageObject = JSONObject(messageSlot.captured) + val dataObject = messageObject.getJSONObject(PasskeyReplyChannel.DATA_KEY) + assertEquals(0, dataObject.length()) } @Test - fun `postError with string message sends correct error format`() { + fun `postError with string sends correct error format`() { // Given val errorMessage = "Test error message" val messageSlot = slot() @@ -103,197 +99,194 @@ class PasskeyReplyChannelTest { // Then verify { mockReplyProxy.postMessage(capture(messageSlot)) } - val capturedMessage = messageSlot.captured - val messageArray = JSONArray(capturedMessage) + val messageObject = JSONObject(messageSlot.captured) + val dataObject = messageObject.getJSONObject(PasskeyReplyChannel.DATA_KEY) - assertEquals(3, messageArray.length()) - assertEquals(PasskeyReplyChannel.ERROR_STATUS, messageArray.getString(0)) - assertEquals(errorMessage, messageArray.getString(1)) - assertEquals(testRequestType, messageArray.getString(2)) + assertEquals(errorMessage, dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_MESSAGE_KEY)) + assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_NOT_ALLOWED_ERROR, + dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) + assertEquals(0, dataObject.getInt(PasskeyReplyChannel.DOM_EXCEPTION_CODE_KEY)) } @Test - fun `postError with throwable sends correct error format`() { + fun `postError with cancellation exception returns NotAllowedError`() { // Given - val exceptionMessage = "Exception occurred" - val testException = RuntimeException(exceptionMessage) + val exception = CreateCredentialCancellationException("User cancelled") val messageSlot = slot() // When - passkeyReplyChannel.postError(testException) + passkeyReplyChannel.postError(exception) // Then verify { mockReplyProxy.postMessage(capture(messageSlot)) } - val capturedMessage = messageSlot.captured - val messageArray = JSONArray(capturedMessage) - - assertEquals(3, messageArray.length()) - assertEquals(PasskeyReplyChannel.ERROR_STATUS, messageArray.getString(0)) - assertEquals(exceptionMessage, messageArray.getString(1)) - assertEquals(testRequestType, messageArray.getString(2)) + val dataObject = JSONObject(messageSlot.captured).getJSONObject(PasskeyReplyChannel.DATA_KEY) + assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_NOT_ALLOWED_ERROR, + dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) } @Test - fun `postError with throwable handles null message`() { + fun `postError with interruption exception returns AbortError`() { // Given - val testException = RuntimeException(null as String?) + val exception = CreateCredentialInterruptedException("Interrupted") val messageSlot = slot() // When - passkeyReplyChannel.postError(testException) + passkeyReplyChannel.postError(exception) // Then verify { mockReplyProxy.postMessage(capture(messageSlot)) } - val capturedMessage = messageSlot.captured - val messageArray = JSONArray(capturedMessage) - - assertEquals(3, messageArray.length()) - assertEquals(PasskeyReplyChannel.ERROR_STATUS, messageArray.getString(0)) - assertEquals("Unknown error", messageArray.getString(1)) - assertEquals(testRequestType, messageArray.getString(2)) + val dataObject = JSONObject(messageSlot.captured).getJSONObject(PasskeyReplyChannel.DATA_KEY) + assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_ABORT_ERROR, + dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) } @Test - fun `send method handles postMessage exceptions gracefully`() { + fun `postError with configuration exception returns NotSupportedError`() { // Given - val testJson = """{"key": "value"}""" - every { mockReplyProxy.postMessage(any()) } throws RuntimeException("PostMessage failed") + val exception = CreateCredentialProviderConfigurationException("Config missing") + val messageSlot = slot() - // When/Then - Should not throw exception - assertDoesNotThrow { - passkeyReplyChannel.postSuccess(testJson) - } + // When + passkeyReplyChannel.postError(exception) - verify { mockReplyProxy.postMessage(any()) } + // Then + verify { mockReplyProxy.postMessage(capture(messageSlot)) } + + val dataObject = JSONObject(messageSlot.captured).getJSONObject(PasskeyReplyChannel.DATA_KEY) + assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_NOT_SUPPORTED_ERROR, + dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) } @Test - fun `constructor with default request type uses unknown`() { + fun `postError with unknown exception returns UnknownError`() { // Given - val channelWithDefaultType = PasskeyReplyChannel(mockReplyProxy) + val exception = CreateCredentialUnknownException("Unknown error") val messageSlot = slot() // When - channelWithDefaultType.postSuccess("""{"test": "data"}""") + passkeyReplyChannel.postError(exception) // Then verify { mockReplyProxy.postMessage(capture(messageSlot)) } - val capturedMessage = messageSlot.captured - val messageArray = JSONArray(capturedMessage) - assertEquals("unknown", messageArray.getString(2)) + val dataObject = JSONObject(messageSlot.captured).getJSONObject(PasskeyReplyChannel.DATA_KEY) + assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_UNKNOWN_ERROR, + dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) } @Test - fun `ReplyMessage Success toString creates valid JSON array`() { + fun `postError with NoCredentialException returns NotAllowedError`() { // Given - val testJson = """{"key": "value"}""" - val successMessage = PasskeyReplyChannel.ReplyMessage.Success(testJson, testRequestType) + val exception = NoCredentialException("No credentials") + val messageSlot = slot() // When - val result = successMessage.toString() + passkeyReplyChannel.postError(exception) // Then - val messageArray = JSONArray(result) - assertEquals(3, messageArray.length()) - assertEquals(PasskeyReplyChannel.SUCCESS_STATUS, messageArray.getString(0)) - assertEquals(testRequestType, messageArray.getString(2)) + verify { mockReplyProxy.postMessage(capture(messageSlot)) } - val dataObject = messageArray.getJSONObject(1) - assertEquals("value", dataObject.getString("key")) + val dataObject = JSONObject(messageSlot.captured).getJSONObject(PasskeyReplyChannel.DATA_KEY) + assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_NOT_ALLOWED_ERROR, + dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) } @Test - fun `ReplyMessage Error toString creates valid JSON array`() { + fun `postError with generic exception returns NotAllowedError`() { // Given - val errorMessage = "Test error" - val errorReplyMessage = PasskeyReplyChannel.ReplyMessage.Error(errorMessage, testRequestType) + val exception = RuntimeException("Generic error") + val messageSlot = slot() // When - val result = errorReplyMessage.toString() + passkeyReplyChannel.postError(exception) // Then - val messageArray = JSONArray(result) - assertEquals(3, messageArray.length()) - assertEquals(PasskeyReplyChannel.ERROR_STATUS, messageArray.getString(0)) - assertEquals(errorMessage, messageArray.getString(1)) - assertEquals(testRequestType, messageArray.getString(2)) + verify { mockReplyProxy.postMessage(capture(messageSlot)) } + + val dataObject = JSONObject(messageSlot.captured).getJSONObject(PasskeyReplyChannel.DATA_KEY) + assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_NOT_ALLOWED_ERROR, + dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) } @Test - fun `ReplyMessage Success handles malformed JSON in toString`() { + fun `postError handles null exception message`() { // Given - val malformedJson = "not a valid json" - val successMessage = PasskeyReplyChannel.ReplyMessage.Success(malformedJson, testRequestType) + val exception = RuntimeException(null as String?) + val messageSlot = slot() // When - val result = successMessage.toString() + passkeyReplyChannel.postError(exception) // Then - assertDoesNotThrow { - val messageArray = JSONArray(result) - assertEquals(3, messageArray.length()) - assertEquals(PasskeyReplyChannel.SUCCESS_STATUS, messageArray.getString(0)) - assertEquals(malformedJson, messageArray.getString(1)) // Should use raw string - assertEquals(testRequestType, messageArray.getString(2)) - } + verify { mockReplyProxy.postMessage(capture(messageSlot)) } + + val dataObject = JSONObject(messageSlot.captured).getJSONObject(PasskeyReplyChannel.DATA_KEY) + assertEquals("Unknown error (empty message)", + dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_MESSAGE_KEY)) } @Test - fun `constants have correct values`() { - assertEquals("success", PasskeyReplyChannel.SUCCESS_STATUS) - assertEquals("error", PasskeyReplyChannel.ERROR_STATUS) - assertEquals("PasskeyReplyChannel", PasskeyReplyChannel.TAG) + fun `send handles postMessage exceptions gracefully`() { + // Given + val testJson = """{"key": "value"}""" + every { mockReplyProxy.postMessage(any()) } throws RuntimeException("PostMessage failed") + + // When/Then - Should not throw + assertDoesNotThrow { + passkeyReplyChannel.postSuccess(testJson) + } + + verify { mockReplyProxy.postMessage(any()) } } @Test - fun `ReplyMessage Success handles empty JSON object`() { + fun `constructor uses unknown as default request type`() { // Given - val emptyJson = "{}" - val successMessage = PasskeyReplyChannel.ReplyMessage.Success(emptyJson, testRequestType) + val channelWithDefaultType = PasskeyReplyChannel(mockReplyProxy) + val messageSlot = slot() // When - val result = successMessage.toString() + channelWithDefaultType.postSuccess("""{"test": "data"}""") // Then - val messageArray = JSONArray(result) - assertEquals(3, messageArray.length()) - assertEquals(PasskeyReplyChannel.SUCCESS_STATUS, messageArray.getString(0)) + verify { mockReplyProxy.postMessage(capture(messageSlot)) } - val dataObject = messageArray.getJSONObject(1) - assertEquals(0, dataObject.length()) // Empty JSON object - assertEquals(testRequestType, messageArray.getString(2)) + val messageObject = JSONObject(messageSlot.captured) + assertEquals("unknown", messageObject.getString(PasskeyReplyChannel.TYPE_KEY)) } @Test fun `ReplyMessage Success handles complex JSON structures`() { // Given - val complexJson = """ - { - "nested": { - "array": [1, 2, 3], - "boolean": true, - "null": null - }, - "string": "test" - } - """.trimIndent() + val complexJson = """{"nested": {"array": [1, 2, 3], "boolean": true}, "string": "test"}""" val successMessage = PasskeyReplyChannel.ReplyMessage.Success(complexJson, testRequestType) // When val result = successMessage.toString() // Then - val messageArray = JSONArray(result) - assertEquals(3, messageArray.length()) - assertEquals(PasskeyReplyChannel.SUCCESS_STATUS, messageArray.getString(0)) + val messageObject = JSONObject(result) + assertEquals(PasskeyReplyChannel.SUCCESS_STATUS, messageObject.getString(PasskeyReplyChannel.STATUS_KEY)) - val dataObject = messageArray.getJSONObject(1) + val dataObject = messageObject.getJSONObject(PasskeyReplyChannel.DATA_KEY) assertEquals("test", dataObject.getString("string")) assertEquals(true, dataObject.getJSONObject("nested").getBoolean("boolean")) - assertEquals(testRequestType, messageArray.getString(2)) + } + + @Test + fun `constants have correct values`() { + assertEquals("success", PasskeyReplyChannel.SUCCESS_STATUS) + assertEquals("error", PasskeyReplyChannel.ERROR_STATUS) + assertEquals("PasskeyReplyChannel", PasskeyReplyChannel.TAG) + + assertEquals("NotAllowedError", PasskeyReplyChannel.DOM_EXCEPTION_NOT_ALLOWED_ERROR) + assertEquals("AbortError", PasskeyReplyChannel.DOM_EXCEPTION_ABORT_ERROR) + assertEquals("NotSupportedError", PasskeyReplyChannel.DOM_EXCEPTION_NOT_SUPPORTED_ERROR) + assertEquals("UnknownError", PasskeyReplyChannel.DOM_EXCEPTION_UNKNOWN_ERROR) + + assertEquals(0.toShort(), PasskeyReplyChannel.DOM_EXCEPTION_CODE_UNKNOWN_ERROR) } private fun assertDoesNotThrow(executable: () -> Unit) { From 036fd39136c712404a00d5f21f8b60317d22a5bd Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Fri, 17 Oct 2025 13:25:47 -0700 Subject: [PATCH 12/44] Refactor build.gradle to set project archivesBaseName correctly; update Logger class to disable Logcat logging by default; improve logging conditions in Logger.java; ensure proper newline at end of files in CredentialManagerHandler and JsScriptRecord. --- common/build.gradle | 97 +++++++------------ .../oauth2/CredentialManagerHandler.kt | 2 +- .../internal/ui/webview/JsScriptRecord.kt | 2 +- .../identity/common/logging/Logger.java | 2 +- .../identity/common/java/logging/Logger.java | 4 +- 5 files changed, 41 insertions(+), 66 deletions(-) diff --git a/common/build.gradle b/common/build.gradle index 1ad9f0c1d1..d0fee78758 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -49,9 +49,6 @@ boolean newBrokerDiscoveryEnabledFlag = project.hasProperty("newBrokerDiscoveryE boolean trustDebugBrokerFlag = project.hasProperty("trustDebugBrokerFlag") boolean bypassRedirectUriCheck = project.hasProperty("bypassRedirectUriCheck") -// Set the archives name at project level -base.archivesName = "common" - android { compileSdk rootProject.ext.compileSdkVersion defaultConfig { @@ -61,6 +58,7 @@ android { targetSdkVersion rootProject.ext.targetSdkVersion versionCode getAppVersionCode() versionName getAppVersionName() + project.archivesBaseName = "common" project.version = android.defaultConfig.versionName testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" //Languages Common supports. @@ -147,18 +145,7 @@ android { libraryVariants.all { variant -> variant.outputs.all { - outputFileName = "${base.archivesName.get()}-${version}.aar" - } - } - // 👇 Register your custom variants as publishable - publishing { - singleVariant("distRelease") { - withSourcesJar() - withJavadocJar() - } - singleVariant("distDebug") { - withSourcesJar() - withJavadocJar() + outputFileName = "${archivesBaseName}-${version}.aar" } } } @@ -203,8 +190,6 @@ dependencies { implementation "com.google.android.gms:play-services-fido:$rootProject.ext.LegacyFidoApiVersion" implementation "com.google.android.libraries.identity.googleid:googleid:$rootProject.ext.GoogleIdVersion" implementation "com.github.stephenc.jcip:jcip-annotations:$rootProject.ext.jcipAnnotationVersion" - implementation "androidx.webkit:webkit:1.14.0" - constraints { implementation ("com.squareup.okio:okio:3.4.0") { @@ -321,84 +306,71 @@ tasks.register('pmd', Pmd) { html.required = true } } - // For publishing to the remote maven repo. afterEvaluate { + publishing { publications { - // --- distRelease Publication --- - create("distRelease", MavenPublication) { - from(components["distRelease"]) - - groupId = "com.microsoft.identity" - artifactId = "common" + distRelease(MavenPublication) { + from components.distRelease + groupId 'com.microsoft.identity' + artifactId 'common' + //Edit the 'version' here for VSTS RC build version = project.version pom { - name.set("common") - description.set(""" - This library contains code shared between the Active Directory - Authentication Library (ADAL) for Android and the Microsoft - Authentication Library (MSAL) for Android. This library - includes only internal classes and is NOT part of the - public API. - """.stripIndent()) - url.set("https://github.com/AzureAD/microsoft-authentication-library-common-for-android") - + name = 'common' + description = 'This library contains code shared between the Active Directory ' + + 'Authentication Library (ADAL) for Android and the Microsoft ' + + 'Authentication Library (MSAL) for Android. This library ' + + 'includes only internal classes and is NOT part of the ' + + 'public API' + url = 'https://github.com/AzureAD/microsoft-authentication-library-common-for-android' developers { developer { - id.set("microsoft") - name.set("Microsoft") + id = 'microsoft' + name = 'Microsoft' } } - licenses { license { - name.set("MIT License") + name = 'MIT License' } } - - inceptionYear.set("2017") - + inceptionYear = '2017' scm { - url.set("https://github.com/AzureAD/microsoft-authentication-library-common-for-android/tree/master") + url = 'https://github.com/AzureAD/microsoft-authentication-library-common-for-android/tree/master' } - - properties.set([ - "branch": "master", - "version": version - ]) + properties = [ + branch : 'master', + version: project.version + ] } } + distDebug(MavenPublication) { + from components.distDebug + groupId 'com.microsoft.identity' + artifactId 'common-debug' + //Edit the 'version' here for VSTS RC build - // --- distDebug Publication --- - create("distDebug", MavenPublication) { - from(components["distDebug"]) - - groupId = "com.microsoft.identity" - artifactId = "common-debug" version = project.version } } + // Repositories to which Gradle can publish artifacts repositories { maven { - name = "vsts-maven-adal-android" - url = uri("https://identitydivision.pkgs.visualstudio.com/_packaging/AndroidADAL/maven/v1") - + name "vsts-maven-adal-android" + url "https://identitydivision.pkgs.visualstudio.com/_packaging/AndroidADAL/maven/v1" credentials { - username = System.getenv("ENV_VSTS_MVN_ANDROIDCOMMON_USERNAME") - ?: project.findProperty("vstsUsername") - password = System.getenv("ENV_VSTS_MVN_ANDROIDCOMMON_ACCESSTOKEN") - ?: project.findProperty("vstsMavenAccessToken") + username System.getenv("ENV_VSTS_MVN_ANDROIDCOMMON_USERNAME") != null ? System.getenv("ENV_VSTS_MVN_ANDROIDCOMMON_USERNAME") : project.findProperty("vstsUsername") + password System.getenv("ENV_VSTS_MVN_ANDROIDCOMMON_ACCESSTOKEN") != null ? System.getenv("ENV_VSTS_MVN_ANDROIDCOMMON_ACCESSTOKEN") : project.findProperty("vstsMavenAccessToken") } } } } } - - tasks.configureEach { task -> if (task.name.contains("assemble")) { task.dependsOn 'pmd' @@ -458,3 +430,4 @@ tasks.register("dependenciesSizeCheck") { } } } + diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt index f50fd48ac8..63f8d42b88 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt @@ -86,4 +86,4 @@ class CredentialManagerHandler(private val activity: Activity) { throw e } } -} \ No newline at end of file +} diff --git a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/JsScriptRecord.kt b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/JsScriptRecord.kt index 6a270ca6c8..2d6b1e105b 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/JsScriptRecord.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/JsScriptRecord.kt @@ -55,4 +55,4 @@ class JsScriptRecord( // Check if the URL starts with any allowed prefix return allowedUrls.any { prefix -> url.startsWith(prefix) } } -} \ No newline at end of file +} diff --git a/common/src/main/java/com/microsoft/identity/common/logging/Logger.java b/common/src/main/java/com/microsoft/identity/common/logging/Logger.java index 9ed1ef0707..5c6abaebad 100644 --- a/common/src/main/java/com/microsoft/identity/common/logging/Logger.java +++ b/common/src/main/java/com/microsoft/identity/common/logging/Logger.java @@ -40,7 +40,7 @@ public class Logger { private static final Logger INSTANCE = new Logger(); // Disable to Logcat logging by default. - private static boolean sAllowLogcat = true; + private static boolean sAllowLogcat = false; /** * Enum class for LogLevel that the sdk recognizes. diff --git a/common4j/src/main/com/microsoft/identity/common/java/logging/Logger.java b/common4j/src/main/com/microsoft/identity/common/java/logging/Logger.java index 2ce6cfa7d3..0dc3862b0f 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/logging/Logger.java +++ b/common4j/src/main/com/microsoft/identity/common/java/logging/Logger.java @@ -472,7 +472,9 @@ private static void log(final String tag, @Nullable final String objectToLog, final Throwable throwable, final boolean containsPII) { - + if ((sLogLevel == LogLevel.NO_LOG) || logLevel.compareTo(sLogLevel) > 0 || (!sAllowPii && containsPII)) { + return; + } final Date now = new Date(); final String diagnosticMetadata = getDiagnosticContextMetadata(correlationId); From 8304ba562a321c99f3eff04e1054b854ae3857ad Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Fri, 17 Oct 2025 15:53:40 -0700 Subject: [PATCH 13/44] Add WebView support for Passkey functionality; update dependencies and clean up request headers --- common/build.gradle | 1 + .../providers/oauth2/WebViewAuthorizationFragment.java | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/common/build.gradle b/common/build.gradle index d0fee78758..d6eb3aa31f 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -151,6 +151,7 @@ android { } dependencies { + implementation 'androidx.webkit:webkit:1.14.0' testImplementation project(path: ':testutils') localApi(project(":common4j")) { diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java index 501a90c88f..72f1369885 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java @@ -542,9 +542,6 @@ private void setupPasskeyWebListener(@NonNull final WebView webView, // Downgrade to auth only mRequestHeaders.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_ONLY); } - //TEMPORARRY CHANGE - mRequestHeaders.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_ONLY); - } else { Logger.warn(methodTag, "Passkey protocol header not found or not for both auth and reg." + " Not hooking the PasskeyWebListener."); From 8b31fd0b4707e54fa0efd59c476866ae95b1f4f3 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Fri, 17 Oct 2025 17:37:11 -0700 Subject: [PATCH 14/44] Update minified JavaScript code in PasskeyWebListener; add detailed instructions for modifying js-bridge.js --- .../providers/oauth2/PasskeyWebListener.kt | 17 +++++++++--- .../internal/providers/oauth2/js-bridge.js | 26 ++++++++++++++++--- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt index 59db04a8e3..ddd885ee29 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt @@ -231,11 +231,22 @@ class PasskeyWebListener( /** * Minified JavaScript code that intercepts WebAuthn API calls. - * Source: credmanweb/javascript/encode.js + * + * ⚠️ IMPORTANT: This is the MINIFIED version of js-bridge.js + * + * Source file: common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/js-bridge.js + * + * When updating: + * 1. Modify the source file (js-bridge.js) with your changes + * 2. Minify the updated JavaScript code + * 3. Replace the string below with the new minified version + * 4. Verify the minified code works correctly through testing + * + * DO NOT modify this constant directly - always update the source file first! */ private const val WEB_AUTHN_INTERFACE_JS_MINIFIED = """ - var __webauthn_interface__,__webauthn_hooks__;!function(e){__webauthn_interface__.addEventListener("message",(function(e){console.log(e.data);var n=JSON.parse(e.data);"get"===n.type?o(n):"create"===n.type?l(n):console.log("Incorrect response format for reply: "+n.type)}));var n=null,t=null,r=null,a=null;function o(e){if(null!==n&&null!==r){if("success"!=e.status){var o=r;return n=null,r=null,void o(new DOMException(e.data.domExceptionMessage,e.data.domExceptionName))}var s=u(e.data),i=n;n=null,r=null,i(s)}else console.log("Reply failure: Resolve: "+t+" and reject: "+a)}function s(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 i(e){return btoa(Array.from(new Uint8Array(e),(function(e){return String.fromCharCode(e)})).join("")).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+${'$'}/,"")}function l(e){if(null!==t&&null!==a){if("success"!=e.status){var n=a;return t=null,a=null,void n(new DOMException(e.data.domExceptionMessage,e.data.domExceptionName))}var r=u(e.data),o=t;t=null,a=null,o(r)}else console.log("Reply failure: Resolve: "+t+" and reject: "+a)}function u(e){return e.rawId=s(e.rawId),e.response.clientDataJSON=s(e.response.clientDataJSON),e.response.hasOwnProperty("attestationObject")&&(e.response.attestationObject=s(e.response.attestationObject)),e.response.hasOwnProperty("authenticatorData")&&(e.response.authenticatorData=s(e.response.authenticatorData)),e.response.hasOwnProperty("signature")&&(e.response.signature=s(e.response.signature)),e.response.hasOwnProperty("userHandle")&&(e.response.userHandle=s(e.response.userHandle)),e.getClientExtensionResults=function(){return{}},e.response.getTransports=function(){return e.response.hasOwnProperty("transports")?e.response.transports:[]},e}e.create=function(n){if(!("publicKey"in n))return e.originalCreateFunction(n);var r=new Promise((function(e,n){t=e,a=n})),o=n.publicKey;if(o.hasOwnProperty("challenge")){var s=i(o.challenge);o.challenge=s}if(o.hasOwnProperty("user")&&o.user.hasOwnProperty("id")){var l=i(o.user.id);o.user.id=l}if(o.hasOwnProperty("excludeCredentials")&&Array.isArray(o.excludeCredentials)&&o.excludeCredentials.length>0)for(var u=0;u0)for(var c=0;c Date: Tue, 21 Oct 2025 15:38:15 -0700 Subject: [PATCH 15/44] Refactor request header handling in WebViewAuthorizationFragment; extract Passkey protocol header injection logic into a separate method for improved readability and maintainability. --- .../oauth2/WebViewAuthorizationFragment.java | 59 +++++++++++-------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java index 72f1369885..84349bc352 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java @@ -433,48 +433,24 @@ public void onDestroy() { */ private HashMap getRequestHeaders(final Bundle state) { try { - final Uri authRequestUri = Uri.parse(mAuthorizationRequestUrl); - final String webAuthNQueryParameter = authRequestUri.getQueryParameter(FidoConstants.WEBAUTHN_QUERY_PARAMETER_FIELD); - final boolean hasWebAuthNQueryParameter = !StringUtil.isNullOrEmpty(webAuthNQueryParameter); - final boolean isPasskeyRegistrationFlightEnabled = CommonFlightsManager.INSTANCE - .getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_PASSKEY_REGISTRATION); // Suppressing unchecked warnings due to casting of serializable String to HashMap @SuppressWarnings(WarningType.unchecked_warning) final HashMap requestHeaders = (HashMap) state.getSerializable(REQUEST_HEADERS); final HashMap headers = requestHeaders != null ? requestHeaders : new HashMap<>(); // Attach client extras header for ESTS telemetry. Only done for broker requests if (isBrokerRequest) { - if (hasWebAuthNQueryParameter && isPasskeyRegistrationFlightEnabled) { - headers.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_AND_REG); - } else { - // If the webauthn query parameter is not present, we should add the header for auth only. - // This to keep the behavior same as before the passkey registration feature was added. - headers.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_ONLY); - } final ClientExtraSku clientExtraSku = ClientExtraSku.builder() .srcSku(state.getString(PRODUCT)) .srcSkuVer(state.getString(VERSION)) .build(); headers.put(com.microsoft.identity.common.java.AuthenticationConstants.SdkPlatformFields.CLIENT_EXTRA_SKU, clientExtraSku.toString()); - } else { - if (hasWebAuthNQueryParameter) { - // Only first party app will be sending the webauthn query parameter and therefore - // they should have declare association in the assetlinks.json file. - // OneAuth and MSAL will control the version of the passkey protocol header on their end. - if (headers.containsKey(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME)) { - Logger.warn(TAG + ":getRequestHeaders", "Passkey protocol header already exists in request headers."); - } else { - headers.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_AND_REG); - } - } - } + injectPasskeyProtocolHeader(headers); return headers; } catch (Exception e) { return new HashMap<>(); } } - @Nullable public ActivityResultLauncher getFidoLauncher() { return mFidoLauncher; @@ -548,4 +524,37 @@ private void setupPasskeyWebListener(@NonNull final WebView webView, } } + /** + * Injects the Passkey protocol header into the request headers if the WebAuthN query parameter is present. + * If the header already exists, it will not be modified. If the request is from broker and the Passkey registration flight is enabled, + * the header will indicate support for both authentication and registration. + * + * @param requestHeaders The request headers to modify. + */ + private void injectPasskeyProtocolHeader(@NonNull final HashMap requestHeaders) { + final String methodTag = TAG + ":injectPasskeyProtocolHeader"; + final Uri authRequestUri = Uri.parse(mAuthorizationRequestUrl); + final String webAuthNQueryParameter = authRequestUri.getQueryParameter(FidoConstants.WEBAUTHN_QUERY_PARAMETER_FIELD); + final boolean hasWebAuthNQueryParameter = !StringUtil.isNullOrEmpty(webAuthNQueryParameter); + + if (!hasWebAuthNQueryParameter) { + return; + } + + if (requestHeaders.containsKey(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME)) { + Logger.verbose(methodTag, "Passkey protocol header already exists in request headers " + + requestHeaders.get(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME)); + return; + } + + final boolean isPasskeyRegistrationFlightEnabled = CommonFlightsManager.INSTANCE + .getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_PASSKEY_REGISTRATION); + if (isBrokerRequest && isPasskeyRegistrationFlightEnabled){ + Logger.verbose(methodTag, "Injecting Passkey protocol header for both auth and reg."); + requestHeaders.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_AND_REG); + } + + Logger.verbose(methodTag, "Injecting Passkey protocol header for auth only."); + requestHeaders.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_ONLY); + } } From 89f6544251ca4cef6c154b004ff772f8c359e820 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Tue, 21 Oct 2025 16:16:26 -0700 Subject: [PATCH 16/44] Update dependencies in build.gradle; replace hardcoded webkit version with project property for better version management. Refactor FidoChallengeField to support multiple Passkey protocol versions; improve error handling for unsupported versions. Clean up CredentialManagerHandler by removing unnecessary exception handling; streamline credential creation and retrieval logic. Add unit tests for PasskeyWebListener; cover message handling, credential flows, and error scenarios. Add webkitVersion variable in versions.gradle for centralized version management. --- common/build.gradle | 2 +- .../internal/fido/FidoChallengeField.kt | 6 +- .../oauth2/CredentialManagerHandler.kt | 18 +- .../oauth2/PasskeyReplyChannelTest.kt | 3 - .../oauth2/PasskeyWebListenerTest.kt | 558 ++++++++++++++++++ gradle/versions.gradle | 1 + 6 files changed, 565 insertions(+), 23 deletions(-) create mode 100644 common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListenerTest.kt diff --git a/common/build.gradle b/common/build.gradle index d6eb3aa31f..fff15c63e3 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -151,7 +151,7 @@ android { } dependencies { - implementation 'androidx.webkit:webkit:1.14.0' + implementation "androidx.webkit:webkit:$rootProject.ext.webkitVersion" testImplementation project(path: ':testutils') localApi(project(":common4j")) { diff --git a/common/src/main/java/com/microsoft/identity/common/internal/fido/FidoChallengeField.kt b/common/src/main/java/com/microsoft/identity/common/internal/fido/FidoChallengeField.kt index de4e477354..92c10e4650 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/fido/FidoChallengeField.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/fido/FidoChallengeField.kt @@ -112,10 +112,10 @@ data class FidoChallengeField(private val field: FidoRequestField, @Throws(ClientException::class) fun throwIfInvalidProtocolVersion(field: FidoRequestField, value: String?): String { val version = throwIfInvalidRequiredParameter(field, value) - if (version != FidoConstants.PASSKEY_PROTOCOL_VERSION_1_0) { - throw ClientException(ClientException.PASSKEY_PROTOCOL_REQUEST_PARSING_ERROR, "Provided protocol version is not currently supported.") + if (version == FidoConstants.PASSKEY_PROTOCOL_VERSION_1_0 || version == FidoConstants.PASSKEY_PROTOCOL_VERSION_1_1) { + return version } - return version + throw ClientException(ClientException.PASSKEY_PROTOCOL_REQUEST_PARSING_ERROR, "Provided protocol version is not currently supported.") } /** diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt index 63f8d42b88..fb2ea2c4d3 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt @@ -30,8 +30,6 @@ 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 import com.microsoft.identity.common.logging.Logger class CredentialManagerHandler(private val activity: Activity) { @@ -52,14 +50,8 @@ class CredentialManagerHandler(private val activity: Activity) { val methodTag = "$TAG:createPasskey" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { val createRequest = CreatePublicKeyCredentialRequest(request) - try { - return (mCredMan.createCredential(activity, createRequest) as CreatePublicKeyCredentialResponse).also { - Logger.info(methodTag, "Passkey created successfully.") - } - } catch (e: CreateCredentialException) { - // For error handling use guidance from https://developer.android.com/training/sign-in/passkeys - Logger.error(TAG, "Error creating credential: ErrMessage: ${e.errorMessage}, ErrType: ${e.type}", e) - throw e + return (mCredMan.createCredential(activity, createRequest) as CreatePublicKeyCredentialResponse).also { + Logger.info(methodTag, "Passkey created successfully.") } } else { Logger.warn(methodTag, "Passkey creation is not supported on Android versions below 9 (Pie). Current version: ${Build.VERSION.SDK_INT}") @@ -76,14 +68,8 @@ class CredentialManagerHandler(private val activity: Activity) { suspend fun getPasskey(request: String): GetCredentialResponse { val methodTag = "$TAG:getPasskey" val getRequest = GetCredentialRequest(listOf(GetPublicKeyCredentialOption(request))) - try { return mCredMan.getCredential(activity, getRequest).also { Logger.info(methodTag, "Passkey retrieved successfully.") } - } catch (e: GetCredentialException) { - // For error handling use guidance from https://developer.android.com/training/sign-in/passkeys - Logger.error(TAG, "Error retrieving credential: ${e.message}", e) - throw e - } } } diff --git a/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt b/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt index cf7f8e25d5..7720eb72ba 100644 --- a/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt +++ b/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt @@ -105,7 +105,6 @@ class PasskeyReplyChannelTest { assertEquals(errorMessage, dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_MESSAGE_KEY)) assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_NOT_ALLOWED_ERROR, dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) - assertEquals(0, dataObject.getInt(PasskeyReplyChannel.DOM_EXCEPTION_CODE_KEY)) } @Test @@ -285,8 +284,6 @@ class PasskeyReplyChannelTest { assertEquals("AbortError", PasskeyReplyChannel.DOM_EXCEPTION_ABORT_ERROR) assertEquals("NotSupportedError", PasskeyReplyChannel.DOM_EXCEPTION_NOT_SUPPORTED_ERROR) assertEquals("UnknownError", PasskeyReplyChannel.DOM_EXCEPTION_UNKNOWN_ERROR) - - assertEquals(0.toShort(), PasskeyReplyChannel.DOM_EXCEPTION_CODE_UNKNOWN_ERROR) } private fun assertDoesNotThrow(executable: () -> Unit) { diff --git a/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListenerTest.kt b/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListenerTest.kt new file mode 100644 index 0000000000..838709809b --- /dev/null +++ b/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListenerTest.kt @@ -0,0 +1,558 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +package com.microsoft.identity.common.internal.providers.oauth2 + +import android.app.Activity +import android.net.Uri +import android.os.Build +import android.webkit.WebView +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.GetCredentialResponse +import androidx.credentials.PublicKeyCredential +import androidx.credentials.exceptions.CreateCredentialCancellationException +import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.test.core.app.ApplicationProvider +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebMessageCompat +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import com.microsoft.identity.common.internal.ui.webview.AzureActiveDirectoryWebViewClient +import io.mockk.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [PasskeyWebListener]. + * + * Tests WebAuthn message handling, credential creation/retrieval flows, and error handling. + * Uses real objects where possible, mocking only external dependencies like CredentialManager. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P]) // API 28+ required for passkey support +class PasskeyWebListenerTest { + + // Real objects + private lateinit var testScope: CoroutineScope + private lateinit var activity: Activity + private lateinit var webView: WebView + private lateinit var sourceOrigin: Uri + + // Mocked objects (only what's necessary) + private lateinit var mockCredentialManagerHandler: CredentialManagerHandler + private lateinit var mockJavaScriptReplyProxy: JavaScriptReplyProxy + private lateinit var mockWebMessageCompat: WebMessageCompat + private lateinit var mockWebViewClient: AzureActiveDirectoryWebViewClient + + // System under test + private lateinit var passkeyWebListener: PasskeyWebListener + + @Before + fun setUp() { + testScope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + + // Initialize real Activity using Robolectric + activity = Robolectric.buildActivity(Activity::class.java).create().get() + webView = WebView(ApplicationProvider.getApplicationContext()) + sourceOrigin = Uri.parse("https://login.microsoft.com") + + // Mock only external dependencies + mockCredentialManagerHandler = mockk(relaxed = true) + mockJavaScriptReplyProxy = mockk(relaxed = true) + mockWebMessageCompat = mockk() + mockWebViewClient = mockk(relaxed = true) + + // Create listener with test coroutine scope + passkeyWebListener = PasskeyWebListener( + coroutineScope = testScope, + credentialManagerHandler = mockCredentialManagerHandler + ) + } + + @After + fun tearDown() { + clearAllMocks() + } + + // ========== Message Parsing Tests ========== + + @Test + fun `onPostMessage with valid create request processes successfully`() = runBlocking { + // Given + val createRequest = """{"publicKey": {"challenge": "test"}}""" + val message = createValidMessage(PasskeyWebListener.CREATE_UNIQUE_KEY, createRequest) + every { mockWebMessageCompat.data } returns message + + val mockResponse = mockk() + val responseJson = """{"type":"public-key","rawId":"dGVzdA=="}""" + every { mockResponse.registrationResponseJson } returns responseJson + coEvery { mockCredentialManagerHandler.createPasskey(createRequest) } returns mockResponse + + val messageSlot = slot() + + // When + passkeyWebListener.onPostMessage( + webView, + mockWebMessageCompat, + sourceOrigin, + isMainFrame = true, + mockJavaScriptReplyProxy + ) + + // Then + verify(timeout = 1000) { mockJavaScriptReplyProxy.postMessage(capture(messageSlot)) } + val responseObject = JSONObject(messageSlot.captured) + assertEquals(PasskeyReplyChannel.SUCCESS_STATUS, responseObject.getString(PasskeyReplyChannel.STATUS_KEY)) + assertEquals(PasskeyWebListener.CREATE_UNIQUE_KEY, responseObject.getString(PasskeyReplyChannel.TYPE_KEY)) + } + + @Test + fun `onPostMessage with valid get request processes successfully`() = runBlocking { + // Given + val getRequest = """{"publicKey": {"challenge": "test"}}""" + val message = createValidMessage(PasskeyWebListener.GET_UNIQUE_KEY, getRequest) + every { mockWebMessageCompat.data } returns message + + val mockCredential = mockk() + val authResponseJson = """{"type":"public-key","rawId":"dGVzdA=="}""" + every { mockCredential.authenticationResponseJson } returns authResponseJson + + val mockResponse = mockk() + every { mockResponse.credential } returns mockCredential + coEvery { mockCredentialManagerHandler.getPasskey(getRequest) } returns mockResponse + + val messageSlot = slot() + + // When + passkeyWebListener.onPostMessage( + webView, + mockWebMessageCompat, + sourceOrigin, + isMainFrame = true, + mockJavaScriptReplyProxy + ) + + // Then + verify(timeout = 1000) { mockJavaScriptReplyProxy.postMessage(capture(messageSlot)) } + val responseObject = JSONObject(messageSlot.captured) + assertEquals(PasskeyReplyChannel.SUCCESS_STATUS, responseObject.getString(PasskeyReplyChannel.STATUS_KEY)) + assertEquals(PasskeyWebListener.GET_UNIQUE_KEY, responseObject.getString(PasskeyReplyChannel.TYPE_KEY)) + } + + @Test + fun `onPostMessage with empty message data sends error`() { + // Given + every { mockWebMessageCompat.data } returns "" + val messageSlot = slot() + + // When + passkeyWebListener.onPostMessage( + webView, + mockWebMessageCompat, + sourceOrigin, + isMainFrame = true, + mockJavaScriptReplyProxy + ) + + // Then + verify { mockJavaScriptReplyProxy.postMessage(capture(messageSlot)) } + val responseObject = JSONObject(messageSlot.captured) + assertEquals(PasskeyReplyChannel.ERROR_STATUS, responseObject.getString(PasskeyReplyChannel.STATUS_KEY)) + val dataObject = responseObject.getJSONObject(PasskeyReplyChannel.DATA_KEY) + assertTrue(dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_MESSAGE_KEY).contains("empty")) + } + + @Test + fun `onPostMessage with null message data sends error`() { + // Given + every { mockWebMessageCompat.data } returns null + val messageSlot = slot() + + // When + passkeyWebListener.onPostMessage( + webView, + mockWebMessageCompat, + sourceOrigin, + isMainFrame = true, + mockJavaScriptReplyProxy + ) + + // Then + verify { mockJavaScriptReplyProxy.postMessage(capture(messageSlot)) } + val responseObject = JSONObject(messageSlot.captured) + assertEquals(PasskeyReplyChannel.ERROR_STATUS, responseObject.getString(PasskeyReplyChannel.STATUS_KEY)) + } + + @Test + fun `onPostMessage with missing type key sends error`() { + // Given + val invalidMessage = """{"request": "test"}""" + every { mockWebMessageCompat.data } returns invalidMessage + val messageSlot = slot() + + // When + passkeyWebListener.onPostMessage( + webView, + mockWebMessageCompat, + sourceOrigin, + isMainFrame = true, + mockJavaScriptReplyProxy + ) + + // Then + verify { mockJavaScriptReplyProxy.postMessage(capture(messageSlot)) } + val responseObject = JSONObject(messageSlot.captured) + assertEquals(PasskeyReplyChannel.ERROR_STATUS, responseObject.getString(PasskeyReplyChannel.STATUS_KEY)) + val dataObject = responseObject.getJSONObject(PasskeyReplyChannel.DATA_KEY) + assertTrue(dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_MESSAGE_KEY).contains("type")) + } + + @Test + fun `onPostMessage with missing request key sends error`() { + // Given + val invalidMessage = """{"type": "create"}""" + every { mockWebMessageCompat.data } returns invalidMessage + val messageSlot = slot() + + // When + passkeyWebListener.onPostMessage( + webView, + mockWebMessageCompat, + sourceOrigin, + isMainFrame = true, + mockJavaScriptReplyProxy + ) + + // Then + verify { mockJavaScriptReplyProxy.postMessage(capture(messageSlot)) } + val responseObject = JSONObject(messageSlot.captured) + assertEquals(PasskeyReplyChannel.ERROR_STATUS, responseObject.getString(PasskeyReplyChannel.STATUS_KEY)) + val dataObject = responseObject.getJSONObject(PasskeyReplyChannel.DATA_KEY) + assertTrue(dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_MESSAGE_KEY).contains("request")) + } + + @Test + fun `onPostMessage with invalid JSON sends error`() { + // Given + every { mockWebMessageCompat.data } returns "not valid json" + val messageSlot = slot() + + // When + passkeyWebListener.onPostMessage( + webView, + mockWebMessageCompat, + sourceOrigin, + isMainFrame = true, + mockJavaScriptReplyProxy + ) + + // Then + verify { mockJavaScriptReplyProxy.postMessage(capture(messageSlot)) } + val responseObject = JSONObject(messageSlot.captured) + assertEquals(PasskeyReplyChannel.ERROR_STATUS, responseObject.getString(PasskeyReplyChannel.STATUS_KEY)) + } + + @Test + fun `onPostMessage with unknown request type sends error`() { + // Given + val message = createValidMessage("unknown_type", """{"test": "data"}""") + every { mockWebMessageCompat.data } returns message + val messageSlot = slot() + + // When + passkeyWebListener.onPostMessage( + webView, + mockWebMessageCompat, + sourceOrigin, + isMainFrame = true, + mockJavaScriptReplyProxy + ) + + // Then + verify { mockJavaScriptReplyProxy.postMessage(capture(messageSlot)) } + val responseObject = JSONObject(messageSlot.captured) + assertEquals(PasskeyReplyChannel.ERROR_STATUS, responseObject.getString(PasskeyReplyChannel.STATUS_KEY)) + val dataObject = responseObject.getJSONObject(PasskeyReplyChannel.DATA_KEY) + assertTrue(dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_MESSAGE_KEY).contains("Unknown request type")) + } + + // ========== Frame Origin Tests ========== + + @Test + fun `onPostMessage from iframe sends error`() { + // Given + val message = createValidMessage(PasskeyWebListener.CREATE_UNIQUE_KEY, """{"test": "data"}""") + every { mockWebMessageCompat.data } returns message + val messageSlot = slot() + + // When + passkeyWebListener.onPostMessage( + webView, + mockWebMessageCompat, + sourceOrigin, + isMainFrame = false, // Not main frame + mockJavaScriptReplyProxy + ) + + // Then + verify { mockJavaScriptReplyProxy.postMessage(capture(messageSlot)) } + val responseObject = JSONObject(messageSlot.captured) + assertEquals(PasskeyReplyChannel.ERROR_STATUS, responseObject.getString(PasskeyReplyChannel.STATUS_KEY)) + val dataObject = responseObject.getJSONObject(PasskeyReplyChannel.DATA_KEY) + assertTrue(dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_MESSAGE_KEY).contains("iframe")) + } + + // ========== Concurrent Request Tests ========== + + @Test + fun `onPostMessage rejects concurrent requests`() = runBlocking { + // Given - First request that will take time + val firstRequest = createValidMessage(PasskeyWebListener.CREATE_UNIQUE_KEY, """{"test": "data1"}""") + every { mockWebMessageCompat.data } returns firstRequest + + val mockResponse = mockk() + every { mockResponse.registrationResponseJson } returns """{"id":"test"}""" + coEvery { mockCredentialManagerHandler.createPasskey(any()) } coAnswers { + kotlinx.coroutines.delay(100) // Simulate long operation + mockResponse + } + + // When - Send first request + passkeyWebListener.onPostMessage( + webView, + mockWebMessageCompat, + sourceOrigin, + isMainFrame = true, + mockJavaScriptReplyProxy + ) + + // Send second request immediately (before first completes) + val secondReplyProxy = mockk(relaxed = true) + val secondMessage = createValidMessage(PasskeyWebListener.GET_UNIQUE_KEY, """{"test": "data2"}""") + val secondWebMessage = mockk() + every { secondWebMessage.data } returns secondMessage + + val messageSlot = slot() + passkeyWebListener.onPostMessage( + webView, + secondWebMessage, + sourceOrigin, + isMainFrame = true, + secondReplyProxy + ) + + // Then - Second request should be rejected immediately + verify { secondReplyProxy.postMessage(capture(messageSlot)) } + val responseObject = JSONObject(messageSlot.captured) + assertEquals(PasskeyReplyChannel.ERROR_STATUS, responseObject.getString(PasskeyReplyChannel.STATUS_KEY)) + val dataObject = responseObject.getJSONObject(PasskeyReplyChannel.DATA_KEY) + assertTrue(dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_MESSAGE_KEY).contains("already in progress")) + } + + // ========== Error Handling Tests ========== + + @Test + fun `create request handles cancellation exception`() = runBlocking { + // Given + val createRequest = """{"publicKey": {"challenge": "test"}}""" + val message = createValidMessage(PasskeyWebListener.CREATE_UNIQUE_KEY, createRequest) + every { mockWebMessageCompat.data } returns message + + coEvery { mockCredentialManagerHandler.createPasskey(createRequest) } throws + CreateCredentialCancellationException("User cancelled") + + val messageSlot = slot() + + // When + passkeyWebListener.onPostMessage( + webView, + mockWebMessageCompat, + sourceOrigin, + isMainFrame = true, + mockJavaScriptReplyProxy + ) + + // Then + verify(timeout = 1000) { mockJavaScriptReplyProxy.postMessage(capture(messageSlot)) } + val responseObject = JSONObject(messageSlot.captured) + assertEquals(PasskeyReplyChannel.ERROR_STATUS, responseObject.getString(PasskeyReplyChannel.STATUS_KEY)) + val dataObject = responseObject.getJSONObject(PasskeyReplyChannel.DATA_KEY) + assertEquals( + PasskeyReplyChannel.DOM_EXCEPTION_NOT_ALLOWED_ERROR, + dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY) + ) + } + + @Test + fun `get request handles cancellation exception`() = runBlocking { + // Given + val getRequest = """{"publicKey": {"challenge": "test"}}""" + val message = createValidMessage(PasskeyWebListener.GET_UNIQUE_KEY, getRequest) + every { mockWebMessageCompat.data } returns message + + coEvery { mockCredentialManagerHandler.getPasskey(getRequest) } throws + GetCredentialCancellationException("User cancelled") + + val messageSlot = slot() + + // When + passkeyWebListener.onPostMessage( + webView, + mockWebMessageCompat, + sourceOrigin, + isMainFrame = true, + mockJavaScriptReplyProxy + ) + + // Then + verify(timeout = 1000) { mockJavaScriptReplyProxy.postMessage(capture(messageSlot)) } + val responseObject = JSONObject(messageSlot.captured) + assertEquals(PasskeyReplyChannel.ERROR_STATUS, responseObject.getString(PasskeyReplyChannel.STATUS_KEY)) + val dataObject = responseObject.getJSONObject(PasskeyReplyChannel.DATA_KEY) + assertEquals( + PasskeyReplyChannel.DOM_EXCEPTION_NOT_ALLOWED_ERROR, + dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY) + ) + } + + @Test + fun `create request handles generic exception`() = runBlocking { + // Given + val createRequest = """{"publicKey": {"challenge": "test"}}""" + val message = createValidMessage(PasskeyWebListener.CREATE_UNIQUE_KEY, createRequest) + every { mockWebMessageCompat.data } returns message + + coEvery { mockCredentialManagerHandler.createPasskey(createRequest) } throws + RuntimeException("Unexpected error") + + val messageSlot = slot() + + // When + passkeyWebListener.onPostMessage( + webView, + mockWebMessageCompat, + sourceOrigin, + isMainFrame = true, + mockJavaScriptReplyProxy + ) + + // Then + verify(timeout = 1000) { mockJavaScriptReplyProxy.postMessage(capture(messageSlot)) } + val responseObject = JSONObject(messageSlot.captured) + assertEquals(PasskeyReplyChannel.ERROR_STATUS, responseObject.getString(PasskeyReplyChannel.STATUS_KEY)) + } + + // ========== Hook Method Tests ========== + + @Test + @Config(sdk = [Build.VERSION_CODES.O_MR1]) // API 27 - below minimum + fun `hook returns false on API below 28`() { + // When + val result = PasskeyWebListener.hook(webView, activity, mockWebViewClient) + + // Then + assertFalse(result) + verify(exactly = 0) { mockWebViewClient.addOnPageStartedScript(any(), any(), any()) } + } + + @Test + fun `hook returns true and sets up listener on supported devices`() { + // Given + mockkStatic(WebViewFeature::class) + every { WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) } returns true + + mockkStatic(WebViewCompat::class) + every { + WebViewCompat.addWebMessageListener( + any(), + any(), + any(), + any() + ) + } just Runs + + // When + val result = PasskeyWebListener.hook(webView, activity, mockWebViewClient) + + // Then + assertTrue(result) + verify { + WebViewCompat.addWebMessageListener( + webView, + any(), + any(), + any() + ) + } + verify { + mockWebViewClient.addOnPageStartedScript( + PasskeyWebListener.TAG, + any(), + any() + ) + } + + unmockkStatic(WebViewFeature::class) + unmockkStatic(WebViewCompat::class) + } + + @Test + fun `hook returns false when WEB_MESSAGE_LISTENER not supported`() { + // Given + mockkStatic(WebViewFeature::class) + every { WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) } returns false + + // When + val result = PasskeyWebListener.hook(webView, activity, mockWebViewClient) + + // Then + assertFalse(result) + verify(exactly = 0) { mockWebViewClient.addOnPageStartedScript(any(), any(), any()) } + + unmockkStatic(WebViewFeature::class) + } + + // ========== Helper Methods ========== + + /** + * Creates a valid WebAuthn message JSON string. + */ + private fun createValidMessage(type: String, request: String): String { + return JSONObject().apply { + put(PasskeyWebListener.TYPE_KEY, type) + put(PasskeyWebListener.REQUEST_KEY, request) + }.toString() + } +} + diff --git a/gradle/versions.gradle b/gradle/versions.gradle index e32931e4fc..3751f637aa 100644 --- a/gradle/versions.gradle +++ b/gradle/versions.gradle @@ -80,6 +80,7 @@ ext { AndroidCredentialsVersion="1.2.2" LegacyFidoApiVersion="20.1.0" GoogleIdVersion="1.1.0" + webkitVersion="1.14.0" // microsoft-diagnostics-uploader app versions powerliftVersion = "0.14.7" From f172ccb0232b5e7ea5d778f09e341d2e6eac039a Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Wed, 22 Oct 2025 17:54:01 -0700 Subject: [PATCH 17/44] Remove passkey feature flight toggle; set passkey registration to disabled by default. --- .../ui/webview/AzureActiveDirectoryWebViewClient.java | 2 +- .../identity/common/java/flighting/CommonFlight.java | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java index 3e316fa06f..5a70c3c295 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java @@ -254,7 +254,7 @@ private boolean handleUrl(final WebView view, final String url) { final PKeyAuthChallenge pKeyAuthChallenge = factory.getPKeyAuthChallengeFromWebViewRedirect(url); final PKeyAuthChallengeHandler pKeyAuthChallengeHandler = new PKeyAuthChallengeHandler(view, getCompletionCallback()); pKeyAuthChallengeHandler.processChallenge(pKeyAuthChallenge); - } else if (CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_PASSKEY_FEATURE) && isPasskeyUrl(formattedURL)) { + } else if (isPasskeyUrl(formattedURL)) { Logger.info(methodTag,"WebView detected request for passkey protocol."); final FidoChallenge challenge = FidoChallenge.createFromRedirectUri(url); final Activity currentActivity = getActivity(); diff --git a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java index f9f88c6f34..d5e842c921 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java +++ b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java @@ -54,17 +54,11 @@ public enum CommonFlight implements IFlightConfig { */ ACQUIRE_TOKEN_SILENT_TIMEOUT_MILLISECONDS("AcquireTokenSilentTimeoutMilliSeconds", ACQUIRE_TOKEN_SILENT_DEFAULT_TIMEOUT_MILLISECONDS), - /** - * Flight to be able to disable/rollback the passkey feature in broker if necessary. - * This will be set to false by default. - */ - ENABLE_PASSKEY_FEATURE("EnablePasskeyFeature", false), - /** * Flight to be able to disable/rollback the passkey feature in broker if necessary. * This will be set to true by default. */ - ENABLE_PASSKEY_REGISTRATION("EnablePasskeyRegistration", true), + ENABLE_PASSKEY_REGISTRATION("EnablePasskeyRegistration", false), /** * Flight to control the timeout duration for UrlConnection connect timeout. From d87a5ccf0638c6b217e34117dbc02e258589d479 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Wed, 22 Oct 2025 23:28:42 -0700 Subject: [PATCH 18/44] Enhance Passkey protocol header handling in WebViewAuthorizationFragment; streamline logic for injecting headers based on flight feature and broker requests. --- .../providers/oauth2/PasskeyWebListener.kt | 37 +++++++++++++++++-- .../oauth2/WebViewAuthorizationFragment.java | 27 ++++++++------ 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt index ddd885ee29..5ca15a9475 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt @@ -32,6 +32,7 @@ import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature +import com.microsoft.identity.common.BuildConfig import com.microsoft.identity.common.internal.ui.webview.AzureActiveDirectoryWebViewClient import com.microsoft.identity.common.logging.Logger import kotlinx.coroutines.CoroutineScope @@ -249,7 +250,37 @@ class PasskeyWebListener( """ /** Allowed origins that can use the WebAuthn interface. */ - private val ALLOWED_ORIGIN_RULES = setOf("https://login.microsoft.com", "https://account.live.com") + private val ALLOWED_ORIGIN_RULES_PRODUCTION = setOf( + "https://login.microsoft.com", + "https://account.live.com", + "https://mysignins.microsoft.com", + "https://mysignins.azure.us", + "https://mysignins.microsoft.scloud", + "https://mysignins.eaglex.ic.gov", + "https://login.microsoftonline.us", + "https://login.microsoftonline.microsoft.scloud", + "https://login.microsoftonline.eaglex.ic.gov" + ) + + /** Allowed origins for pre-production/testing environments. */ + private val ALLOWED_ORIGIN_PRE_PRODUCTION = setOf( + "https://account.live-int.com", + "https://login.windows-ppe.net", + "https://mysignins-ppe.microsoft.com" + ) + + /** + * Gets the set of allowed origin rules based on build configuration. + * + * @return Set of allowed origin rules. + */ + private fun getAllowedOriginRules(): Set { + val mutableSet = ALLOWED_ORIGIN_RULES_PRODUCTION.toMutableSet() + if (BuildConfig.DEBUG) { + mutableSet.addAll(ALLOWED_ORIGIN_PRE_PRODUCTION) + } + return mutableSet.toSet() + } /** * Attaches the passkey listener to a WebView. @@ -289,7 +320,7 @@ class PasskeyWebListener( WebViewCompat.addWebMessageListener( webView, INTERFACE_NAME, - ALLOWED_ORIGIN_RULES, + getAllowedOriginRules(), PasskeyWebListener( coroutineScope = CoroutineScope(Dispatchers.Default), credentialManagerHandler = CredentialManagerHandler(activity) @@ -302,7 +333,7 @@ class PasskeyWebListener( webClient.addOnPageStartedScript( TAG, WEB_AUTHN_INTERFACE_JS_MINIFIED, - ALLOWED_ORIGIN_RULES + getAllowedOriginRules() ) true } else { diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java index 84349bc352..60dcfe4dbd 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java @@ -541,19 +541,22 @@ private void injectPasskeyProtocolHeader(@NonNull final HashMap return; } - if (requestHeaders.containsKey(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME)) { - Logger.verbose(methodTag, "Passkey protocol header already exists in request headers " - + requestHeaders.get(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME)); - return; - } - - final boolean isPasskeyRegistrationFlightEnabled = CommonFlightsManager.INSTANCE - .getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_PASSKEY_REGISTRATION); - if (isBrokerRequest && isPasskeyRegistrationFlightEnabled){ - Logger.verbose(methodTag, "Injecting Passkey protocol header for both auth and reg."); - requestHeaders.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_AND_REG); + if (isBrokerRequest){ + final boolean isPasskeyRegistrationFlightEnabled = CommonFlightsManager.INSTANCE + .getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_PASSKEY_REGISTRATION); + final String passkeyProtocolHeaderValue = isPasskeyRegistrationFlightEnabled + ? FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_AND_REG + : FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_ONLY; + Logger.verbose(methodTag, "Injecting Passkey protocol header for broker request: " + + passkeyProtocolHeaderValue); + requestHeaders.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, passkeyProtocolHeaderValue); + } else { + if (requestHeaders.containsKey(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME)) { + Logger.verbose(methodTag, "Passkey protocol header already exists in request headers " + + requestHeaders.get(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME)); + return; + } } - Logger.verbose(methodTag, "Injecting Passkey protocol header for auth only."); requestHeaders.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_ONLY); } From f206ee53a26fa7533d141695170ca2ebde3ed0a0 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 23 Oct 2025 12:23:39 -0700 Subject: [PATCH 19/44] Refactor createPasskey and getPasskey methods in CredentialManagerHandler for improved readability; format code for better clarity. --- .../providers/oauth2/CredentialManagerHandler.kt | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt index fb2ea2c4d3..22dc4998e2 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt @@ -50,11 +50,17 @@ class CredentialManagerHandler(private val activity: Activity) { val methodTag = "$TAG:createPasskey" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { val createRequest = CreatePublicKeyCredentialRequest(request) - return (mCredMan.createCredential(activity, createRequest) as CreatePublicKeyCredentialResponse).also { + return (mCredMan.createCredential( + activity, + createRequest + ) as CreatePublicKeyCredentialResponse).also { Logger.info(methodTag, "Passkey created successfully.") } } else { - Logger.warn(methodTag, "Passkey creation is not supported on Android versions below 9 (Pie). Current version: ${Build.VERSION.SDK_INT}") + Logger.warn( + methodTag, + "Passkey creation is not supported on Android versions below 9 (Pie). Current version: ${Build.VERSION.SDK_INT}" + ) throw UnsupportedOperationException("Passkey creation requires Android 9 or higher.") } } @@ -68,8 +74,8 @@ class CredentialManagerHandler(private val activity: Activity) { suspend fun getPasskey(request: String): GetCredentialResponse { val methodTag = "$TAG:getPasskey" val getRequest = GetCredentialRequest(listOf(GetPublicKeyCredentialOption(request))) - return mCredMan.getCredential(activity, getRequest).also { - Logger.info(methodTag, "Passkey retrieved successfully.") - } + return mCredMan.getCredential(activity, getRequest).also { + Logger.info(methodTag, "Passkey retrieved successfully.") + } } } From 71170101f8198a23447187c4fb4e15cf7ba231db Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 23 Oct 2025 12:25:48 -0700 Subject: [PATCH 20/44] Add passkey registration support for WebView in changelog --- changelog.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.txt b/changelog.txt index 620fd99360..8548442666 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,6 @@ vNext ---------- +- [MINOR] Add passkey registration support for WebView (#2769) - [MAJOR] Add KeyStoreBackedSecretKeyProvider (#2674) - [MINOR] Add Open Id configuration issuer validation reporting in OpenIdProviderConfigurationClient (#2751) - [MINOR] Add helper method to record elapsed time (#2768) From 8e13fde3ed54727f68600fc3dc4e1015dad6bd7d Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 23 Oct 2025 12:27:52 -0700 Subject: [PATCH 21/44] Update default dependencies size to 16 MB in build.gradle --- common/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/build.gradle b/common/build.gradle index fff15c63e3..cfd84e40b0 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -410,7 +410,7 @@ tasks.withType(GenerateMavenPom).all { } } -def dependenciesSizeInMb = project.hasProperty("dependenciesSizeMb") ? project.dependenciesSizeMb : "15" +def dependenciesSizeInMb = project.hasProperty("dependenciesSizeMb") ? project.dependenciesSizeMb : "16" String configToCheck = project.hasProperty("dependenciesSizeCheckConfig") ? project.dependenciesSizeCheckConfig : "distReleaseRuntimeClasspath" tasks.register("dependenciesSizeCheck") { doLast() { From 6c20ed85c758e412f616d14af6fbd779e279488a Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 23 Oct 2025 12:51:03 -0700 Subject: [PATCH 22/44] Add support for dynamic passkey protocol version validation - Introduced a set of supported passkey protocol versions in FidoConstants. - Updated throwIfInvalidProtocolVersion method to validate against the new set. --- .../common/internal/fido/FidoChallengeField.kt | 2 +- .../identity/common/java/constants/FidoConstants.kt | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/fido/FidoChallengeField.kt b/common/src/main/java/com/microsoft/identity/common/internal/fido/FidoChallengeField.kt index 92c10e4650..9ff57b5495 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/fido/FidoChallengeField.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/fido/FidoChallengeField.kt @@ -112,7 +112,7 @@ data class FidoChallengeField(private val field: FidoRequestField, @Throws(ClientException::class) fun throwIfInvalidProtocolVersion(field: FidoRequestField, value: String?): String { val version = throwIfInvalidRequiredParameter(field, value) - if (version == FidoConstants.PASSKEY_PROTOCOL_VERSION_1_0 || version == FidoConstants.PASSKEY_PROTOCOL_VERSION_1_1) { + if (FidoConstants.supportedPasskeyProtocolVersions.contains(version)) { return version } throw ClientException(ClientException.PASSKEY_PROTOCOL_REQUEST_PARSING_ERROR, "Provided protocol version is not currently supported.") diff --git a/common4j/src/main/com/microsoft/identity/common/java/constants/FidoConstants.kt b/common4j/src/main/com/microsoft/identity/common/java/constants/FidoConstants.kt index fdbf7882da..541ac36304 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/constants/FidoConstants.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/constants/FidoConstants.kt @@ -77,6 +77,18 @@ class FidoConstants { */ const val PASSKEY_PROTOCOL_VERSION_1_1 = "1.1" + /** + * Set of supported passkey protocol versions. + * + * This defines the protocol versions that are recognized and compatible + * with the current implementation. + */ + val supportedPasskeyProtocolVersions = setOf( + PASSKEY_PROTOCOL_VERSION_1_0, + PASSKEY_PROTOCOL_VERSION_1_1 + ) + + /** * Constant to put in PASSKEY_PROTOCOL_KEY_TYPES_SUPPORTED if we support passkeys. */ From 8bbf3d4275d95d7f13d9ac1ed59c32ec79d047bf Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 23 Oct 2025 13:03:54 -0700 Subject: [PATCH 23/44] Refactor code for improved readability and formatting in PasskeyReplyChannel, PasskeyWebListener, and WebViewAuthorizationFragment --- .../providers/oauth2/PasskeyReplyChannel.kt | 8 ++-- .../providers/oauth2/PasskeyWebListener.kt | 37 ++++++++++++++----- .../oauth2/WebViewAuthorizationFragment.java | 7 ++-- 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt index 8b74cb5270..76a454eb69 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt @@ -89,7 +89,8 @@ class PasskeyReplyChannel( */ class Success(val json: String, override val type: String) : ReplyMessage() { override val status = SUCCESS_STATUS - override val data: JSONObject = runCatching { JSONObject(json) }.getOrElse { JSONObject() } + override val data: JSONObject = + runCatching { JSONObject(json) }.getOrElse { JSONObject() } } /** @@ -102,7 +103,8 @@ class PasskeyReplyChannel( class Error( private val domExceptionMessage: String, private val domExceptionName: String = DOM_EXCEPTION_NOT_ALLOWED_ERROR, - override val type: String) : ReplyMessage(){ + override val type: String + ) : ReplyMessage() { override val status = ERROR_STATUS override val data: JSONObject get() { @@ -217,7 +219,7 @@ class PasskeyReplyChannel( val methodTag = "$TAG:send" try { replyProxy.postMessage(message.toString()) - }catch (t: Throwable) { + } catch (t: Throwable) { Logger.error(methodTag, "Reply message failed", t) } } diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt index 5ca15a9475..56286b7b43 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt @@ -97,9 +97,13 @@ class PasskeyWebListener( webAuthnMessage: WebAuthnMessage, sourceOrigin: Uri, isMainFrame: Boolean, - javaScriptReplyProxy: JavaScriptReplyProxy) { + javaScriptReplyProxy: JavaScriptReplyProxy + ) { val methodTag = "$TAG:onRequest" - Logger.info(methodTag, "Received WebAuthn request of type: ${webAuthnMessage.type} from origin: $sourceOrigin") + Logger.info( + methodTag, + "Received WebAuthn request of type: ${webAuthnMessage.type} from origin: $sourceOrigin" + ) val passkeyReplyChannel = PasskeyReplyChannel(javaScriptReplyProxy, webAuthnMessage.type) // Only allow one request at a time. @@ -119,13 +123,23 @@ class PasskeyWebListener( when (webAuthnMessage.type) { CREATE_UNIQUE_KEY -> this.coroutineScope.launch { - handleCreateFlow(credentialManagerHandler, webAuthnMessage.request, passkeyReplyChannel) + handleCreateFlow( + credentialManagerHandler, + webAuthnMessage.request, + passkeyReplyChannel + ) havePendingRequest.set(false) } + GET_UNIQUE_KEY -> this.coroutineScope.launch { - handleGetFlow(credentialManagerHandler, webAuthnMessage.request, passkeyReplyChannel) + handleGetFlow( + credentialManagerHandler, + webAuthnMessage.request, + passkeyReplyChannel + ) havePendingRequest.set(false) } + else -> { passkeyReplyChannel.postError("Unknown request type: ${webAuthnMessage.type}") havePendingRequest.set(false) @@ -143,7 +157,8 @@ class PasskeyWebListener( private suspend fun handleGetFlow( credentialManagerHandler: CredentialManagerHandler, message: String, - reply: PasskeyReplyChannel) { + reply: PasskeyReplyChannel + ) { try { val getCredentialResponse = credentialManagerHandler.getPasskey(message) reply.postSuccess( @@ -164,7 +179,8 @@ class PasskeyWebListener( private suspend fun handleCreateFlow( credentialManagerHandler: CredentialManagerHandler, message: String, - reply: PasskeyReplyChannel) { + reply: PasskeyReplyChannel + ) { try { val createCredentialResponse = credentialManagerHandler.createPasskey(message) reply.postSuccess(createCredentialResponse.registrationResponseJson) @@ -182,7 +198,10 @@ class PasskeyWebListener( * @param javaScriptReplyProxy Proxy for error responses. * @return Parsed [WebAuthnMessage] or null if invalid. */ - private fun parseMessage(messageData: String?, javaScriptReplyProxy: JavaScriptReplyProxy): WebAuthnMessage? { + private fun parseMessage( + messageData: String?, + javaScriptReplyProxy: JavaScriptReplyProxy + ): WebAuthnMessage? { val passkeyReplyChannel = PasskeyReplyChannel(javaScriptReplyProxy) if (messageData.isNullOrBlank()) { @@ -195,7 +214,7 @@ class PasskeyWebListener( val type = json.optString(TYPE_KEY).takeIf { it.isNotBlank() } val request = json.optString(REQUEST_KEY).takeIf { it.isNotBlank() } - if (type == null ) { + if (type == null) { passkeyReplyChannel.postError("Missing required key: type") null } else if (request == null) { @@ -277,7 +296,7 @@ class PasskeyWebListener( private fun getAllowedOriginRules(): Set { val mutableSet = ALLOWED_ORIGIN_RULES_PRODUCTION.toMutableSet() if (BuildConfig.DEBUG) { - mutableSet.addAll(ALLOWED_ORIGIN_PRE_PRODUCTION) + mutableSet.addAll(ALLOWED_ORIGIN_PRE_PRODUCTION) } return mutableSet.toSet() } diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java index 60dcfe4dbd..0e5f7ef37d 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java @@ -541,10 +541,9 @@ private void injectPasskeyProtocolHeader(@NonNull final HashMap return; } - if (isBrokerRequest){ - final boolean isPasskeyRegistrationFlightEnabled = CommonFlightsManager.INSTANCE - .getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_PASSKEY_REGISTRATION); - final String passkeyProtocolHeaderValue = isPasskeyRegistrationFlightEnabled + if (isBrokerRequest) { + final String passkeyProtocolHeaderValue = CommonFlightsManager.INSTANCE + .getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_PASSKEY_REGISTRATION) ? FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_AND_REG : FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_ONLY; Logger.verbose(methodTag, "Injecting Passkey protocol header for broker request: " From d611ac033781ccdcc4720a0cad3be75e0d1e4eec Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Fri, 24 Oct 2025 14:22:09 -0700 Subject: [PATCH 24/44] Enhance passkey functionality by adding span context retrieval, improving error handling, and updating WebView authorization logic --- .../providers/oauth2/PasskeyReplyChannel.kt | 3 + .../providers/oauth2/PasskeyWebListener.kt | 103 +++++++++--------- .../oauth2/WebViewAuthorizationFragment.java | 3 +- .../oauth2/PasskeyReplyChannelTest.kt | 17 +-- .../oauth2/PasskeyWebListenerTest.kt | 2 +- .../common/java/flighting/CommonFlight.java | 3 +- 6 files changed, 62 insertions(+), 69 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt index 76a454eb69..1acc62d5b0 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt @@ -35,6 +35,7 @@ import androidx.credentials.exceptions.NoCredentialException import androidx.webkit.JavaScriptReplyProxy import com.microsoft.identity.common.logging.Logger import org.json.JSONObject +import kotlin.jvm.Throws /** @@ -214,6 +215,7 @@ class PasskeyReplyChannel( /** * Sends a message to JavaScript via the reply proxy. */ + @Throws (Throwable::class) @SuppressLint("RequiresFeature", "Only called when feature is available") private fun send(message: ReplyMessage) { val methodTag = "$TAG:send" @@ -221,6 +223,7 @@ class PasskeyReplyChannel( replyProxy.postMessage(message.toString()) } catch (t: Throwable) { Logger.error(methodTag, "Reply message failed", t) + throw t } } } diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt index 56286b7b43..427c6e5c9d 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt @@ -34,6 +34,7 @@ import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import com.microsoft.identity.common.BuildConfig import com.microsoft.identity.common.internal.ui.webview.AzureActiveDirectoryWebViewClient +import com.microsoft.identity.common.java.exception.ClientException import com.microsoft.identity.common.logging.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -42,7 +43,7 @@ import org.json.JSONObject import java.util.concurrent.atomic.AtomicBoolean /** - * WebView message listener for handling WebAuthn/Passkey authentication flows. + * WebView message listener for handling WebAuthN/Passkey authentication flows. * * Intercepts postMessage() calls from JavaScript to handle credential creation and retrieval * using the Android Credential Manager API. Only accepts requests from allowed origins. @@ -55,11 +56,11 @@ class PasskeyWebListener( private val credentialManagerHandler: CredentialManagerHandler, ) : WebViewCompat.WebMessageListener { - /** Tracks if a WebAuthn request is currently pending. Only one request is allowed at a time. */ + /** Tracks if a WebAuthN request is currently pending. Only one request is allowed at a time. */ private val havePendingRequest = AtomicBoolean(false) /** - * Handles postMessage() calls from the web page for WebAuthn requests. + * Handles postMessage() calls from the web page for WebAuthN requests. * * @param view The WebView that received the message. * @param message The message received from the web page. @@ -75,9 +76,9 @@ class PasskeyWebListener( isMainFrame: Boolean, replyProxy: JavaScriptReplyProxy, ) { - parseMessage(message.data, replyProxy)?.let { webAuthnMessage -> + parseMessage(message.data, replyProxy)?.let { webAuthNMessage -> onRequest( - webAuthnMessage = webAuthnMessage, + webAuthNMessage = webAuthNMessage, sourceOrigin = sourceOrigin, isMainFrame = isMainFrame, javaScriptReplyProxy = replyProxy @@ -86,15 +87,15 @@ class PasskeyWebListener( } /** - * Processes an incoming WebAuthn request. + * Processes an incoming WebAuthN request. * - * @param webAuthnMessage Parsed WebAuthn message. + * @param webAuthNMessage Parsed WebAuthN message. * @param sourceOrigin Origin of the request. * @param isMainFrame True if request is from the main frame. * @param javaScriptReplyProxy Proxy for sending responses. */ private fun onRequest( - webAuthnMessage: WebAuthnMessage, + webAuthNMessage: WebAuthNMessage, sourceOrigin: Uri, isMainFrame: Boolean, javaScriptReplyProxy: JavaScriptReplyProxy @@ -102,9 +103,9 @@ class PasskeyWebListener( val methodTag = "$TAG:onRequest" Logger.info( methodTag, - "Received WebAuthn request of type: ${webAuthnMessage.type} from origin: $sourceOrigin" + "Received WebAuthN request of type: ${webAuthNMessage.type} from origin: $sourceOrigin" ) - val passkeyReplyChannel = PasskeyReplyChannel(javaScriptReplyProxy, webAuthnMessage.type) + val passkeyReplyChannel = PasskeyReplyChannel(javaScriptReplyProxy, webAuthNMessage.type) // Only allow one request at a time. if (havePendingRequest.get()) { @@ -120,12 +121,12 @@ class PasskeyWebListener( return } - when (webAuthnMessage.type) { + when (webAuthNMessage.type) { CREATE_UNIQUE_KEY -> this.coroutineScope.launch { handleCreateFlow( credentialManagerHandler, - webAuthnMessage.request, + webAuthNMessage.request, passkeyReplyChannel ) havePendingRequest.set(false) @@ -134,21 +135,21 @@ class PasskeyWebListener( GET_UNIQUE_KEY -> this.coroutineScope.launch { handleGetFlow( credentialManagerHandler, - webAuthnMessage.request, + webAuthNMessage.request, passkeyReplyChannel ) havePendingRequest.set(false) } else -> { - passkeyReplyChannel.postError("Unknown request type: ${webAuthnMessage.type}") + passkeyReplyChannel.postError("Unknown request type: ${webAuthNMessage.type}") havePendingRequest.set(false) } } } /** - * Handles the WebAuthn get flow to retrieve an existing passkey. + * Handles the WebAuthN get flow to retrieve an existing passkey. * * @param credentialManagerHandler Handler for credential operations. * @param message JSON string with the get request parameters. @@ -159,18 +160,18 @@ class PasskeyWebListener( message: String, reply: PasskeyReplyChannel ) { - try { - val getCredentialResponse = credentialManagerHandler.getPasskey(message) - reply.postSuccess( - (getCredentialResponse.credential as PublicKeyCredential).authenticationResponseJson - ) - } catch (t: Throwable) { - reply.postError(t) - } + runCatching { credentialManagerHandler.getPasskey(message) } + .onSuccess { credentialResponse -> + reply.postSuccess( + (credentialResponse.credential as PublicKeyCredential).authenticationResponseJson + ) } + .onFailure { throwable -> + reply.postError(throwable) + } } /** - * Handles the WebAuthn create flow to register a new passkey. + * Handles the WebAuthN create flow to register a new passkey. * * @param credentialManagerHandler Handler for credential operations. * @param message JSON string with the create request parameters. @@ -181,63 +182,59 @@ class PasskeyWebListener( message: String, reply: PasskeyReplyChannel ) { - try { - val createCredentialResponse = credentialManagerHandler.createPasskey(message) - reply.postSuccess(createCredentialResponse.registrationResponseJson) - } catch (t: Throwable) { - reply.postError(t) - } + runCatching { credentialManagerHandler.createPasskey(message) } + .onSuccess { createCredentialResponse -> + reply.postSuccess(createCredentialResponse.registrationResponseJson) + } + .onFailure { throwable -> + reply.postError(throwable) + } } /** - * Parses a JSON message into a [WebAuthnMessage]. + * Parses a JSON message into a [WebAuthNMessage]. * * Expected format: `{"type": "create|get", "request": ""}` * * @param messageData JSON string to parse. * @param javaScriptReplyProxy Proxy for error responses. - * @return Parsed [WebAuthnMessage] or null if invalid. + * @return Parsed [WebAuthNMessage] or null if invalid. */ private fun parseMessage( messageData: String?, javaScriptReplyProxy: JavaScriptReplyProxy - ): WebAuthnMessage? { - + ): WebAuthNMessage? { val passkeyReplyChannel = PasskeyReplyChannel(javaScriptReplyProxy) - if (messageData.isNullOrBlank()) { - passkeyReplyChannel.postError("Received empty message data") - return null - } - return runCatching { + if (messageData.isNullOrBlank()) { + throw ClientException(ClientException.MISSING_PARAMETER, "Message data is null or blank") + } val json = JSONObject(messageData) val type = json.optString(TYPE_KEY).takeIf { it.isNotBlank() } val request = json.optString(REQUEST_KEY).takeIf { it.isNotBlank() } if (type == null) { - passkeyReplyChannel.postError("Missing required key: type") - null + throw ClientException(ClientException.MISSING_PARAMETER, "Missing required key: type") } else if (request == null) { - passkeyReplyChannel.postError("Missing required key: request") - null + throw ClientException(ClientException.MISSING_PARAMETER, "Missing required key: request") } else { - WebAuthnMessage(type, request) + WebAuthNMessage(type, request) } }.onFailure { throwable -> passkeyReplyChannel.postError(throwable) }.getOrNull() } - /** Internal representation of a WebAuthn message with type and request payload. */ - private data class WebAuthnMessage(val type: String, val request: String) + /** Internal representation of a WebAuthN message with type and request payload. */ + private data class WebAuthNMessage(val type: String, val request: String) companion object { const val TAG = "PasskeyWebListener" - /** WebAuthn request type for creating a new credential. */ + /** WebAuthN request type for creating a new credential. */ const val CREATE_UNIQUE_KEY = "create" - /** WebAuthn request type for retrieving an existing credential. */ + /** WebAuthN request type for retrieving an existing credential. */ const val GET_UNIQUE_KEY = "get" /** JSON key for the request type field. */ @@ -247,10 +244,10 @@ class PasskeyWebListener( const val REQUEST_KEY = "request" /** Name of the JavaScript message port interface. */ - private const val INTERFACE_NAME = "__webauthn_interface__" + private const val INTERFACE_NAME = "__WebAuthN_interface__" /** - * Minified JavaScript code that intercepts WebAuthn API calls. + * Minified JavaScript code that intercepts WebAuthN API calls. * * ⚠️ IMPORTANT: This is the MINIFIED version of js-bridge.js * @@ -265,10 +262,10 @@ class PasskeyWebListener( * DO NOT modify this constant directly - always update the source file first! */ private const val WEB_AUTHN_INTERFACE_JS_MINIFIED = """ - var __webauthn_interface__,__webauthn_hooks__;!function(e){__webauthn_interface__.addEventListener("message",function e(n){console.log(n.data);var t=JSON.parse(n.data);"get"===t.type?s(t):"create"===t.type?u(t):console.log("Incorrect response format for reply: "+t.type)});var n=null,t=null,r=null,a=null;function s(e){if(null===n||null===r){console.log("Reply failure: Resolve: "+t+" and reject: "+a);return}if("success"!=e.status){var s=r;n=null,r=null,s(new DOMException(e.data.domExceptionMessage,e.data.domExceptionName));return}var o=i(e.data),l=n;n=null,r=null,l(o)}function o(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 l(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===t||null===a){console.log("Reply failure: Resolve: "+t+" and reject: "+a);return}if("success"!=e.status){var n=a;t=null,a=null,n(new DOMException(e.data.domExceptionMessage,e.data.domExceptionName));return}var r=i(e.data),s=t;t=null,a=null,s(r)}function i(e){return e.rawId=o(e.rawId),e.response.clientDataJSON=o(e.response.clientDataJSON),e.response.hasOwnProperty("attestationObject")&&(e.response.attestationObject=o(e.response.attestationObject)),e.response.hasOwnProperty("authenticatorData")&&(e.response.authenticatorData=o(e.response.authenticatorData)),e.response.hasOwnProperty("signature")&&(e.response.signature=o(e.response.signature)),e.response.hasOwnProperty("userHandle")&&(e.response.userHandle=o(e.response.userHandle)),e.getClientExtensionResults=function e(){return{}},e.response.getTransports=function n(){return e.response.hasOwnProperty("transports")?e.response.transports:[]},e}e.create=function n(r){if(!("publicKey"in r))return e.originalCreateFunction(r);var s=new Promise(function(e,n){t=e,a=n}),o=r.publicKey;if(o.hasOwnProperty("challenge")){var u=l(o.challenge);o.challenge=u}if(o.hasOwnProperty("user")&&o.user.hasOwnProperty("id")){var i=l(o.user.id);o.user.id=i}if(o.hasOwnProperty("excludeCredentials")&&Array.isArray(o.excludeCredentials)&&o.excludeCredentials.length>0)for(var c=0;c0)for(var c=0;c final String methodTag = TAG + ":injectPasskeyProtocolHeader"; final Uri authRequestUri = Uri.parse(mAuthorizationRequestUrl); final String webAuthNQueryParameter = authRequestUri.getQueryParameter(FidoConstants.WEBAUTHN_QUERY_PARAMETER_FIELD); - final boolean hasWebAuthNQueryParameter = !StringUtil.isNullOrEmpty(webAuthNQueryParameter); - if (!hasWebAuthNQueryParameter) { + if (StringUtil.isNullOrEmpty(webAuthNQueryParameter)) { return; } diff --git a/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt b/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt index 7720eb72ba..36af7b4707 100644 --- a/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt +++ b/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt @@ -227,16 +227,18 @@ class PasskeyReplyChannelTest { } @Test - fun `send handles postMessage exceptions gracefully`() { + fun `send throws exception when postMessage fails`() { // Given val testJson = """{"key": "value"}""" - every { mockReplyProxy.postMessage(any()) } throws RuntimeException("PostMessage failed") + val expectedException = RuntimeException("PostMessage failed") + every { mockReplyProxy.postMessage(any()) } throws expectedException - // When/Then - Should not throw - assertDoesNotThrow { + // When/Then - Should throw the exception + val thrownException = assertThrows(RuntimeException::class.java) { passkeyReplyChannel.postSuccess(testJson) } + assertEquals("PostMessage failed", thrownException.message) verify { mockReplyProxy.postMessage(any()) } } @@ -286,11 +288,4 @@ class PasskeyReplyChannelTest { assertEquals("UnknownError", PasskeyReplyChannel.DOM_EXCEPTION_UNKNOWN_ERROR) } - private fun assertDoesNotThrow(executable: () -> Unit) { - try { - executable() - } catch (e: Exception) { - fail("Expected no exception, but got: ${e.message}") - } - } } diff --git a/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListenerTest.kt b/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListenerTest.kt index 838709809b..75bdb5171b 100644 --- a/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListenerTest.kt +++ b/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListenerTest.kt @@ -190,7 +190,7 @@ class PasskeyWebListenerTest { val responseObject = JSONObject(messageSlot.captured) assertEquals(PasskeyReplyChannel.ERROR_STATUS, responseObject.getString(PasskeyReplyChannel.STATUS_KEY)) val dataObject = responseObject.getJSONObject(PasskeyReplyChannel.DATA_KEY) - assertTrue(dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_MESSAGE_KEY).contains("empty")) + assertTrue(dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_MESSAGE_KEY).contains("Message data is null or blank")) } @Test diff --git a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java index d5e842c921..8715031ece 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java +++ b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java @@ -55,8 +55,7 @@ public enum CommonFlight implements IFlightConfig { ACQUIRE_TOKEN_SILENT_TIMEOUT_MILLISECONDS("AcquireTokenSilentTimeoutMilliSeconds", ACQUIRE_TOKEN_SILENT_DEFAULT_TIMEOUT_MILLISECONDS), /** - * Flight to be able to disable/rollback the passkey feature in broker if necessary. - * This will be set to true by default. + * Flight to enable passkey registration feature. */ ENABLE_PASSKEY_REGISTRATION("EnablePasskeyRegistration", false), From 3a7480b8bd03def4afe26372d3a058b29372776c Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Mon, 27 Oct 2025 15:52:57 -0700 Subject: [PATCH 25/44] Add OpenTelemetry support for passkey operations, enhancing error tracking and span management in PasskeyReplyChannel, PasskeyWebListener, and AzureActiveDirectoryWebViewClient --- .../providers/oauth2/PasskeyReplyChannel.kt | 40 ++++++++++++++++--- .../providers/oauth2/PasskeyWebListener.kt | 32 ++++++++++++--- .../AzureActiveDirectoryWebViewClient.java | 20 +++++----- .../java/opentelemetry/AttributeName.java | 7 +++- .../common/java/opentelemetry/SpanName.java | 3 +- 5 files changed, 81 insertions(+), 21 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt index 1acc62d5b0..7e8d3a8d7b 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt @@ -33,7 +33,10 @@ import androidx.credentials.exceptions.GetCredentialProviderConfigurationExcepti import androidx.credentials.exceptions.GetCredentialUnknownException import androidx.credentials.exceptions.NoCredentialException import androidx.webkit.JavaScriptReplyProxy +import com.microsoft.identity.common.java.opentelemetry.AttributeName +import com.microsoft.identity.common.java.opentelemetry.SpanExtension import com.microsoft.identity.common.logging.Logger +import io.opentelemetry.api.trace.StatusCode import org.json.JSONObject import kotlin.jvm.Throws @@ -78,8 +81,12 @@ class PasskeyReplyChannel( * Sealed class representing messages sent to JavaScript. */ sealed class ReplyMessage { + // Message type (e.g., "create", "get"). abstract val type: String + // Message status ("success" or "error"). abstract val status: String + // Message data as a JSON object. + // Either credential data for success or {domExceptionMessage, domExceptionName} for error. abstract val data: JSONObject /** @@ -102,8 +109,8 @@ class PasskeyReplyChannel( * @property type Request type that failed. */ class Error( - private val domExceptionMessage: String, - private val domExceptionName: String = DOM_EXCEPTION_NOT_ALLOWED_ERROR, + val domExceptionMessage: String, + val domExceptionName: String = DOM_EXCEPTION_NOT_ALLOWED_ERROR, override val type: String ) : ReplyMessage() { override val status = ERROR_STATUS @@ -134,6 +141,11 @@ class PasskeyReplyChannel( fun postSuccess(json: String) { val methodTag = "$TAG:postSuccess" send(ReplyMessage.Success(json, requestType)) + SpanExtension.current().apply { + setStatus(StatusCode.OK) + setAttribute(AttributeName.passkey_operation_type.name, requestType) + end() + } Logger.info(methodTag, "RequestType: $requestType, was successful.") } @@ -146,6 +158,12 @@ class PasskeyReplyChannel( postErrorInternal( ReplyMessage.Error(domExceptionMessage = errorMessage, type = requestType) ) + SpanExtension.current().apply { + setAttribute(AttributeName.passkey_operation_type.name, requestType) + setStatus(StatusCode.ERROR) + setAttribute(AttributeName.error_message.name, errorMessage) + end() + } } /** @@ -157,6 +175,12 @@ class PasskeyReplyChannel( */ fun postError(throwable: Throwable) { postErrorInternal(throwableToErrorMessage(throwable)) + SpanExtension.current().apply { + setAttribute(AttributeName.passkey_operation_type.name, requestType) + setStatus(StatusCode.ERROR) + recordException(throwable) + end() + } } /** @@ -221,9 +245,15 @@ class PasskeyReplyChannel( val methodTag = "$TAG:send" try { replyProxy.postMessage(message.toString()) - } catch (t: Throwable) { - Logger.error(methodTag, "Reply message failed", t) - throw t + } catch (throwable: Throwable) { + SpanExtension.current().apply { + setStatus(StatusCode.ERROR) + setAttribute(AttributeName.passkey_operation_type.name, requestType) + recordException(throwable) + end() + } + Logger.error(methodTag, "Reply message failed", throwable) + throw throwable } } } diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt index 427c6e5c9d..f6ed87d002 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt @@ -35,7 +35,12 @@ import androidx.webkit.WebViewFeature import com.microsoft.identity.common.BuildConfig import com.microsoft.identity.common.internal.ui.webview.AzureActiveDirectoryWebViewClient import com.microsoft.identity.common.java.exception.ClientException +import com.microsoft.identity.common.java.opentelemetry.OTelUtility +import com.microsoft.identity.common.java.opentelemetry.SpanExtension +import com.microsoft.identity.common.java.opentelemetry.SpanName import com.microsoft.identity.common.logging.Logger +import io.opentelemetry.api.trace.Span +import io.opentelemetry.api.trace.SpanContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -54,8 +59,13 @@ import java.util.concurrent.atomic.AtomicBoolean class PasskeyWebListener( private val coroutineScope: CoroutineScope, private val credentialManagerHandler: CredentialManagerHandler, + spanContext: SpanContext? = null ) : WebViewCompat.WebMessageListener { + val span: Span by lazy { + OTelUtility.createSpanFromParent(SpanName.PasskeyWebListener.name, spanContext) + } + /** Tracks if a WebAuthN request is currently pending. Only one request is allowed at a time. */ private val havePendingRequest = AtomicBoolean(false) @@ -76,6 +86,7 @@ class PasskeyWebListener( isMainFrame: Boolean, replyProxy: JavaScriptReplyProxy, ) { + SpanExtension.makeCurrentSpan(span) parseMessage(message.data, replyProxy)?.let { webAuthNMessage -> onRequest( webAuthNMessage = webAuthNMessage, @@ -164,7 +175,8 @@ class PasskeyWebListener( .onSuccess { credentialResponse -> reply.postSuccess( (credentialResponse.credential as PublicKeyCredential).authenticationResponseJson - ) } + ) + } .onFailure { throwable -> reply.postError(throwable) } @@ -207,16 +219,25 @@ class PasskeyWebListener( val passkeyReplyChannel = PasskeyReplyChannel(javaScriptReplyProxy) return runCatching { if (messageData.isNullOrBlank()) { - throw ClientException(ClientException.MISSING_PARAMETER, "Message data is null or blank") + throw ClientException( + ClientException.MISSING_PARAMETER, + "Message data is null or blank" + ) } val json = JSONObject(messageData) val type = json.optString(TYPE_KEY).takeIf { it.isNotBlank() } val request = json.optString(REQUEST_KEY).takeIf { it.isNotBlank() } if (type == null) { - throw ClientException(ClientException.MISSING_PARAMETER, "Missing required key: type") + throw ClientException( + ClientException.MISSING_PARAMETER, + "Missing required key: type" + ) } else if (request == null) { - throw ClientException(ClientException.MISSING_PARAMETER, "Missing required key: request") + throw ClientException( + ClientException.MISSING_PARAMETER, + "Missing required key: request" + ) } else { WebAuthNMessage(type, request) } @@ -339,7 +360,8 @@ class PasskeyWebListener( getAllowedOriginRules(), PasskeyWebListener( coroutineScope = CoroutineScope(Dispatchers.Default), - credentialManagerHandler = CredentialManagerHandler(activity) + credentialManagerHandler = CredentialManagerHandler(activity), + spanContext = webClient.spanContext ) ) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java index 5a70c3c295..7707c2ea70 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java @@ -143,6 +143,8 @@ public class AzureActiveDirectoryWebViewClient extends OAuth2WebViewClient { private final List mOnPageStartedScripts = new ArrayList<>(); + private final SpanContext mSpanContext; + public AzureActiveDirectoryWebViewClient(@NonNull final Activity activity, @NonNull final IAuthorizationCompletionCallback completionCallback, @NonNull final OnPageLoadedCallback pageLoadedCallback, @@ -154,6 +156,7 @@ public AzureActiveDirectoryWebViewClient(@NonNull final Activity activity, mCertBasedAuthFactory = new CertBasedAuthFactory(activity); mSwitchBrowserRequestHandler = switchBrowserRequestHandler; mUtid = utid; + mSpanContext = activity instanceof AuthorizationActivity ? ((AuthorizationActivity) getActivity()).getSpanContext() : null; } /** @@ -1016,9 +1019,7 @@ private void processNonceAndReAttachHeaders(@NonNull final WebView view, @NonNul AttributeName.is_sso_nonce_found_in_ests_request.name(), nonceQueryParam != null ); if (nonceQueryParam != null) { - final SpanContext spanContext = getActivity() instanceof AuthorizationActivity ? ((AuthorizationActivity) getActivity()).getSpanContext() : null; - final Span span = spanContext != null ? - OTelUtility.createSpanFromParent(SpanName.ProcessNonceFromEstsRedirect.name(), spanContext) : OTelUtility.createSpan(SpanName.ProcessNonceFromEstsRedirect.name()); + final Span span = OTelUtility.createSpanFromParent(SpanName.ProcessNonceFromEstsRedirect.name(), mSpanContext); try (final Scope scope = SpanExtension.makeCurrentSpan(span)) { final NonceRedirectHandler nonceRedirect = new NonceRedirectHandler(view, mRequestHeaders, span); nonceRedirect.processChallenge(new URL(url)); @@ -1057,9 +1058,7 @@ private void processWebCpAuthorize(@NonNull final WebView view, @NonNull final S private void processCrossCloudRedirect(@NonNull final WebView view, @NonNull final String url) { final String methodTag = TAG + ":processCrossCloudRedirect"; - final SpanContext spanContext = getActivity() instanceof AuthorizationActivity ? ((AuthorizationActivity) getActivity()).getSpanContext() : null; - final Span span = spanContext != null ? - OTelUtility.createSpanFromParent(SpanName.ProcessCrossCloudRedirect.name(), spanContext) : OTelUtility.createSpan(SpanName.ProcessCrossCloudRedirect.name()); + final Span span = OTelUtility.createSpanFromParent(SpanName.ProcessCrossCloudRedirect.name(), mSpanContext); final ReAttachPrtHeaderHandler reAttachPrtHeaderHandler = new ReAttachPrtHeaderHandler(view, mRequestHeaders, span); reAttachPrtHeader(url, reAttachPrtHeaderHandler, view, methodTag, span); } @@ -1206,9 +1205,7 @@ private String getBrokerAppPackageNameFromUrl(@NonNull final String url) { * @return Created {@link Span} */ private Span createSpanWithAttributesFromParent(@NonNull final String spanName) { - final SpanContext spanContext = getActivity() instanceof AuthorizationActivity ? ((AuthorizationActivity) getActivity()).getSpanContext() : null; - final Span span = spanContext != null ? - OTelUtility.createSpanFromParent(spanName, spanContext) : OTelUtility.createSpan(spanName); + final Span span = OTelUtility.createSpanFromParent(spanName, mSpanContext); if (mUtid != null) { span.setAttribute(AttributeName.tenant_id.name(), mUtid); } @@ -1245,4 +1242,9 @@ public void addOnPageStartedScript( new JsScriptRecord(scriptId, script, allowedUrls) ); } + + public SpanContext getSpanContext() { + return mSpanContext; + } + } diff --git a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java index 69780d9fca..36fb96d825 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java +++ b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java @@ -487,5 +487,10 @@ public enum AttributeName { /** * Records if current flow is in webcp flow. */ - is_in_web_cp_flow + is_in_web_cp_flow, + + /** + * Passkey operation type (e.g., registration, authentication). + */ + passkey_operation_type } diff --git a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/SpanName.java b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/SpanName.java index 6c99bcc48f..e83b90814f 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/SpanName.java +++ b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/SpanName.java @@ -71,5 +71,6 @@ public enum SpanName { ProcessWebsiteRequest, GetAllSsoTokens, ProcessWebCpEnrollmentRedirect, - ProcessWebCpAuthorizeUrlRedirect + ProcessWebCpAuthorizeUrlRedirect, + PasskeyWebListener, } From f038392faa77eba6e57f12a4fb851b87bca720fd Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Tue, 28 Oct 2025 16:56:16 -0700 Subject: [PATCH 26/44] Refactor WebViewAuthorizationFragment for improved code clarity and maintainability --- .../providers/oauth2/WebViewAuthorizationFragment.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java index 4072a00e26..2de5fa6210 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java @@ -552,10 +552,11 @@ private void injectPasskeyProtocolHeader(@NonNull final HashMap if (requestHeaders.containsKey(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME)) { Logger.verbose(methodTag, "Passkey protocol header already exists in request headers " + requestHeaders.get(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME)); - return; + } else { + Logger.verbose(methodTag, "Injecting Passkey protocol header for auth only."); + requestHeaders.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_ONLY); } } - Logger.verbose(methodTag, "Injecting Passkey protocol header for auth only."); - requestHeaders.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_ONLY); + } } From 49c910104ec3272830d1697747141b4d93962c46 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Tue, 28 Oct 2025 16:56:16 -0700 Subject: [PATCH 27/44] Refactor WebViewAuthorizationFragment for improved code clarity and maintainability --- .../providers/oauth2/WebViewAuthorizationFragment.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java index 4072a00e26..2de5fa6210 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java @@ -552,10 +552,11 @@ private void injectPasskeyProtocolHeader(@NonNull final HashMap if (requestHeaders.containsKey(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME)) { Logger.verbose(methodTag, "Passkey protocol header already exists in request headers " + requestHeaders.get(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME)); - return; + } else { + Logger.verbose(methodTag, "Injecting Passkey protocol header for auth only."); + requestHeaders.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_ONLY); } } - Logger.verbose(methodTag, "Injecting Passkey protocol header for auth only."); - requestHeaders.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_ONLY); + } } From 2b1088d7926e5b0479f5a12d0d7831391439284a Mon Sep 17 00:00:00 2001 From: pedro romero vargas <76129899+p3dr0rv@users.noreply.github.com> Date: Wed, 29 Oct 2025 09:56:27 -0700 Subject: [PATCH 28/44] Update common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../common/internal/providers/oauth2/PasskeyReplyChannel.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt index 1acc62d5b0..74474d8bb5 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt @@ -215,7 +215,6 @@ class PasskeyReplyChannel( /** * Sends a message to JavaScript via the reply proxy. */ - @Throws (Throwable::class) @SuppressLint("RequiresFeature", "Only called when feature is available") private fun send(message: ReplyMessage) { val methodTag = "$TAG:send" From 7c0d8ecdd741a70b962ee728c2271138ea93acfc Mon Sep 17 00:00:00 2001 From: pedro romero vargas <76129899+p3dr0rv@users.noreply.github.com> Date: Wed, 29 Oct 2025 10:00:47 -0700 Subject: [PATCH 29/44] Update common/src/main/java/com/microsoft/identity/common/internal/fido/FidoChallengeField.kt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../identity/common/internal/fido/FidoChallengeField.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/fido/FidoChallengeField.kt b/common/src/main/java/com/microsoft/identity/common/internal/fido/FidoChallengeField.kt index 9ff57b5495..2741308dc9 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/fido/FidoChallengeField.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/fido/FidoChallengeField.kt @@ -115,7 +115,10 @@ data class FidoChallengeField(private val field: FidoRequestField, if (FidoConstants.supportedPasskeyProtocolVersions.contains(version)) { return version } - throw ClientException(ClientException.PASSKEY_PROTOCOL_REQUEST_PARSING_ERROR, "Provided protocol version is not currently supported.") + throw ClientException( + ClientException.PASSKEY_PROTOCOL_REQUEST_PARSING_ERROR, + "Passkey protocol version '$version' is not supported. Supported versions: ${FidoConstants.supportedPasskeyProtocolVersions.joinToString()}" + ) } /** From be46370238bf43701524dfe86d7dd11ca96a3942 Mon Sep 17 00:00:00 2001 From: pedro romero vargas <76129899+p3dr0rv@users.noreply.github.com> Date: Wed, 29 Oct 2025 10:05:51 -0700 Subject: [PATCH 30/44] Update common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/js-bridge.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../identity/common/internal/providers/oauth2/js-bridge.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/js-bridge.js b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/js-bridge.js index ca53a1552d..0c90299777 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/js-bridge.js +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/js-bridge.js @@ -61,7 +61,7 @@ var __webauthn_hooks__; } } } - var jsonObj = {"type":"create", "request":temppk} + var jsonObj = {"type":"create", "request":temppk}; var json = JSON.stringify(jsonObj); __webauthn_interface__.postMessage(json); From 999011b1d85f3af46efdb239a65e8e330e027e89 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Wed, 29 Oct 2025 10:11:25 -0700 Subject: [PATCH 31/44] update WebViewAuthorizationFragment logging methods for improved privacy --- .../internal/providers/oauth2/PasskeyWebListener.kt | 10 +++++++--- .../providers/oauth2/WebViewAuthorizationFragment.java | 6 +++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt index 427c6e5c9d..d43c97e8fd 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt @@ -162,9 +162,13 @@ class PasskeyWebListener( ) { runCatching { credentialManagerHandler.getPasskey(message) } .onSuccess { credentialResponse -> - reply.postSuccess( - (credentialResponse.credential as PublicKeyCredential).authenticationResponseJson - ) } + val publicKeyCredential = credentialResponse.credential as? PublicKeyCredential + if (publicKeyCredential != null) { + reply.postSuccess(publicKeyCredential.authenticationResponseJson) + } else { + reply.postError("Unexpected credential type: ${credentialResponse.credential.javaClass.name}") + } + } .onFailure { throwable -> reply.postError(throwable) } diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java index 2de5fa6210..9ff5cb867a 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java @@ -369,13 +369,13 @@ public boolean onConsoleMessage(@NonNull final ConsoleMessage consoleMessage) { consoleMessage.lineNumber() + " of " + consoleMessage.sourceId(); switch (consoleMessage.messageLevel()) { case ERROR: - Logger.error(methodTag, errorMessage, null); + Logger.errorPII(methodTag, errorMessage, null); break; case WARNING: - Logger.warn(methodTag, errorMessage); + Logger.warnPII(methodTag, errorMessage); break; default: - Logger.info(methodTag, errorMessage); + Logger.infoPII(methodTag, errorMessage); } return true; } From 12a2d84d11892070886e68cd8f032b42c3e75a27 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Fri, 31 Oct 2025 17:35:58 -0700 Subject: [PATCH 32/44] Add WebAuthn JavaScript bridge and enhance PasskeyWebListener for debugging --- .../providers/oauth2 => assets}/js-bridge.js | 0 .../providers/oauth2/PasskeyWebListener.kt | 26 ++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) rename common/src/main/{java/com/microsoft/identity/common/internal/providers/oauth2 => assets}/js-bridge.js (100%) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/js-bridge.js b/common/src/main/assets/js-bridge.js similarity index 100% rename from common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/js-bridge.js rename to common/src/main/assets/js-bridge.js diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt index d43c97e8fd..2fc491b29d 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt @@ -23,6 +23,7 @@ package com.microsoft.identity.common.internal.providers.oauth2 import android.app.Activity +import android.content.Context import android.net.Uri import android.os.Build import android.webkit.WebView @@ -330,9 +331,6 @@ class PasskeyWebListener( return false } - // Uncomment for debugging: view console.log messages from injected JS - // WebView.setWebContentsDebuggingEnabled(true) - return if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { Logger.verbose(methodTag, "WEB_MESSAGE_LISTENER is supported on this WebView.") @@ -350,16 +348,36 @@ class PasskeyWebListener( Logger.info(methodTag, "PasskeyWebListener successfully hooked into WebView.") // Injects the JavaScript interface early in the page load lifecycle. + val scriptToInject = if (BuildConfig.DEBUG) { + WebView.setWebContentsDebuggingEnabled(true) + loadJsBridgeScript(activity) + } else { + WEB_AUTHN_INTERFACE_JS_MINIFIED + } webClient.addOnPageStartedScript( TAG, - WEB_AUTHN_INTERFACE_JS_MINIFIED, + scriptToInject, getAllowedOriginRules() ) + true } else { Logger.warn(methodTag, "WEB_MESSAGE_LISTENER not supported on this device/WebView.") false } } + + /** + * Loads the full js-bridge.js script from assets for debugging. + */ + private fun loadJsBridgeScript(context: Context): String { + return try { + context.assets.open("js-bridge.js").bufferedReader().use { it.readText() } + } catch (e: Exception) { + Logger.warn(TAG, "Failed to load js-bridge.js from assets, falling back to minified version: ${e.message}") + WEB_AUTHN_INTERFACE_JS_MINIFIED + } + } + } } From d4c5cf3b12f00806e741343245bb1d3e5383b508 Mon Sep 17 00:00:00 2001 From: pedro romero vargas <76129899+p3dr0rv@users.noreply.github.com> Date: Mon, 3 Nov 2025 12:06:31 -0800 Subject: [PATCH 33/44] Update common/src/main/assets/js-bridge.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- common/src/main/assets/js-bridge.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/main/assets/js-bridge.js b/common/src/main/assets/js-bridge.js index 0c90299777..2a8c02c73e 100644 --- a/common/src/main/assets/js-bridge.js +++ b/common/src/main/assets/js-bridge.js @@ -139,7 +139,7 @@ var __webauthn_hooks__; { return String.fromCharCode(b); }).join('')) .replace(/\+/g, '-') .replace(/\//g, '_') - .replace(/=+${'$'}/, ''); + .replace(/=+$/, ''); } __webauthn_hooks__.CM_base64url_encode = CM_base64url_encode; // Resolves what is expected for create, called when the embedder is ready From 2bf51b770e92e29be3479f818e8a2cdf75183500 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Mon, 3 Nov 2025 12:22:03 -0800 Subject: [PATCH 34/44] Update WebAuthn interface name and modify user verification promise resolution --- common/src/main/assets/js-bridge.js | 2 +- .../common/internal/providers/oauth2/PasskeyWebListener.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/common/src/main/assets/js-bridge.js b/common/src/main/assets/js-bridge.js index 2a8c02c73e..d0c5338a05 100644 --- a/common/src/main/assets/js-bridge.js +++ b/common/src/main/assets/js-bridge.js @@ -201,5 +201,5 @@ navigator.credentials.create = __webauthn_hooks__.create; window.PublicKeyCredential = (function () { }); window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable = function () { - return Promise.resolve(false); + return Promise.resolve(true); }; diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt index 2fc491b29d..b5db958a2c 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt @@ -249,7 +249,7 @@ class PasskeyWebListener( const val REQUEST_KEY = "request" /** Name of the JavaScript message port interface. */ - private const val INTERFACE_NAME = "__WebAuthN_interface__" + private const val INTERFACE_NAME = "__webauthn_interface__" /** * Minified JavaScript code that intercepts WebAuthN API calls. @@ -267,7 +267,7 @@ class PasskeyWebListener( * DO NOT modify this constant directly - always update the source file first! */ private const val WEB_AUTHN_INTERFACE_JS_MINIFIED = """ - var __WebAuthN_interface__,__WebAuthN_hooks__;!function(e){__WebAuthN_interface__.addEventListener("message",function e(n){console.log(n.data);var t=JSON.parse(n.data);"get"===t.type?s(t):"create"===t.type?u(t):console.log("Incorrect response format for reply: "+t.type)});var n=null,t=null,r=null,a=null;function s(e){if(null===n||null===r){console.log("Reply failure: Resolve: "+t+" and reject: "+a);return}if("success"!=e.status){var s=r;n=null,r=null,s(new DOMException(e.data.domExceptionMessage,e.data.domExceptionName));return}var o=i(e.data),l=n;n=null,r=null,l(o)}function o(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 l(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===t||null===a){console.log("Reply failure: Resolve: "+t+" and reject: "+a);return}if("success"!=e.status){var n=a;t=null,a=null,n(new DOMException(e.data.domExceptionMessage,e.data.domExceptionName));return}var r=i(e.data),s=t;t=null,a=null,s(r)}function i(e){return e.rawId=o(e.rawId),e.response.clientDataJSON=o(e.response.clientDataJSON),e.response.hasOwnProperty("attestationObject")&&(e.response.attestationObject=o(e.response.attestationObject)),e.response.hasOwnProperty("authenticatorData")&&(e.response.authenticatorData=o(e.response.authenticatorData)),e.response.hasOwnProperty("signature")&&(e.response.signature=o(e.response.signature)),e.response.hasOwnProperty("userHandle")&&(e.response.userHandle=o(e.response.userHandle)),e.getClientExtensionResults=function e(){return{}},e.response.getTransports=function n(){return e.response.hasOwnProperty("transports")?e.response.transports:[]},e}e.create=function n(r){if(!("publicKey"in r))return e.originalCreateFunction(r);var s=new Promise(function(e,n){t=e,a=n}),o=r.publicKey;if(o.hasOwnProperty("challenge")){var u=l(o.challenge);o.challenge=u}if(o.hasOwnProperty("user")&&o.user.hasOwnProperty("id")){var i=l(o.user.id);o.user.id=i}if(o.hasOwnProperty("excludeCredentials")&&Array.isArray(o.excludeCredentials)&&o.excludeCredentials.length>0)for(var c=0;c0)for(var u=0;u Date: Tue, 4 Nov 2025 18:09:35 -0800 Subject: [PATCH 35/44] Enhance JsScriptRecord to validate sovereign cloud URLs and require 'fido' in the path for specific cases --- .../internal/ui/webview/JsScriptRecord.kt | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/JsScriptRecord.kt b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/JsScriptRecord.kt index 2d6b1e105b..60ba117b79 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/JsScriptRecord.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/JsScriptRecord.kt @@ -38,6 +38,14 @@ class JsScriptRecord( private val allowedUrls: Set? ) { + companion object { + val SOVEREIGN_CLOUD_URL_WITH_EXTRA_VALIDATION = setOf( + "https://login.microsoftonline.us", + "https://login.microsoftonline.microsoft.scloud", + "https://login.microsoftonline.eaglex.ic.gov" + ) + } + /** * Checks whether this script is allowed to execute for the given [url]. * @@ -53,6 +61,14 @@ class JsScriptRecord( if (allowedUrls == null) return true // Check if the URL starts with any allowed prefix - return allowedUrls.any { prefix -> url.startsWith(prefix) } + return allowedUrls.any { prefix -> + if (SOVEREIGN_CLOUD_URL_WITH_EXTRA_VALIDATION.contains(prefix) && url.startsWith(prefix)) { + // For sovereign cloud URLs, require 'fido' in the path + val path = url.removePrefix(prefix) + path.contains("fido", ignoreCase = true) + } else { + url.startsWith(prefix) + } + } } } From 3de6cc409def074341aaa37fe1550eeafd587acc Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 6 Nov 2025 09:09:10 -0800 Subject: [PATCH 36/44] Enhance PasskeyReplyChannel and PasskeyWebListener for improved error handling and telemetry integration --- .../providers/oauth2/PasskeyReplyChannel.kt | 114 +++++++++--------- .../providers/oauth2/PasskeyWebListener.kt | 50 +++++--- .../oauth2/PasskeyReplyChannelTest.kt | 101 +++++++++++++--- .../java/exception/ClientException.java | 10 ++ .../java/opentelemetry/AttributeName.java | 7 +- 5 files changed, 187 insertions(+), 95 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt index fa409cc8c6..adcd5b85e4 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt @@ -34,11 +34,13 @@ import androidx.credentials.exceptions.GetCredentialUnknownException import androidx.credentials.exceptions.NoCredentialException import androidx.webkit.JavaScriptReplyProxy import com.microsoft.identity.common.java.opentelemetry.AttributeName +import com.microsoft.identity.common.java.opentelemetry.OTelUtility import com.microsoft.identity.common.java.opentelemetry.SpanExtension +import com.microsoft.identity.common.java.opentelemetry.SpanName import com.microsoft.identity.common.logging.Logger +import io.opentelemetry.api.trace.SpanContext import io.opentelemetry.api.trace.StatusCode import org.json.JSONObject -import kotlin.jvm.Throws /** @@ -51,7 +53,8 @@ import kotlin.jvm.Throws */ class PasskeyReplyChannel( private val replyProxy: JavaScriptReplyProxy, - private val requestType: String = "unknown" + private val requestType: String = "unknown", + private val spanContext: SpanContext? = null ) { companion object { const val TAG = "PasskeyReplyChannel" @@ -138,34 +141,36 @@ class PasskeyReplyChannel( * * @param json JSON string containing the credential response. */ + @SuppressLint("RequiresFeature", "Only called when feature is available") fun postSuccess(json: String) { val methodTag = "$TAG:postSuccess" - send(ReplyMessage.Success(json, requestType)) - SpanExtension.current().apply { - setStatus(StatusCode.OK) - setAttribute(AttributeName.passkey_operation_type.name, requestType) - end() - } - Logger.info(methodTag, "RequestType: $requestType, was successful.") - } - - /** - * Posts an error message with a custom error description. - * - * @param errorMessage Error description to send. - */ - fun postError(errorMessage: String) { - postErrorInternal( - ReplyMessage.Error(domExceptionMessage = errorMessage, type = requestType) + val span = OTelUtility.createSpanFromParent( + SpanName.PasskeyWebListener.name, + spanContext ) - SpanExtension.current().apply { - setAttribute(AttributeName.passkey_operation_type.name, requestType) - setStatus(StatusCode.ERROR) - setAttribute(AttributeName.error_message.name, errorMessage) - end() + + try { + SpanExtension.makeCurrentSpan(span).use { + val successMessage = ReplyMessage.Success(json, requestType).toString() + replyProxy.postMessage(successMessage) + Logger.info(methodTag, "RequestType: $requestType was successful.") + span.setStatus(StatusCode.OK) + span.setAttribute(AttributeName.passkey_operation_type.name, requestType) + } + } catch (throwable: Throwable) { + span.setStatus(StatusCode.ERROR) + span.setAttribute(AttributeName.passkey_operation_type.name, requestType) + span.recordException(throwable) + Logger.error(methodTag, "Reply message failed", throwable) + throw throwable + } finally { + span.end() } } + + + /** * Posts an error message based on a thrown exception. * @@ -173,23 +178,34 @@ class PasskeyReplyChannel( * * @param throwable Exception to convert and send. */ + @SuppressLint("RequiresFeature", "Only called when feature is available") fun postError(throwable: Throwable) { - postErrorInternal(throwableToErrorMessage(throwable)) - SpanExtension.current().apply { - setAttribute(AttributeName.passkey_operation_type.name, requestType) - setStatus(StatusCode.ERROR) - recordException(throwable) - end() - } - } - - /** - * Internal method to send error messages and log them. - */ - private fun postErrorInternal(errorMessage: ReplyMessage.Error) { val methodTag = "$TAG:postError" - send(errorMessage) - Logger.error(methodTag, "RequestType: $requestType, failed with error: $errorMessage", null) + val span = OTelUtility.createSpanFromParent( + SpanName.PasskeyWebListener.name, + spanContext + ) + + try { + SpanExtension.makeCurrentSpan(span).use { + val errorMessage = throwableToErrorMessage(throwable) + replyProxy.postMessage(errorMessage.toString()) + Logger.error(methodTag, "RequestType: $requestType failed with error: $errorMessage", null) + span.setAttribute(AttributeName.passkey_operation_type.name, requestType) + span.setAttribute(AttributeName.passkey_dom_exception_name.name, errorMessage.domExceptionName) + span.setStatus(StatusCode.ERROR) + span.recordException(throwable) + } + } catch (throwable: Throwable) { + // Handle exception safely, no scope needed + span.setAttribute(AttributeName.passkey_operation_type.name, requestType) + span.setStatus(StatusCode.ERROR) + span.recordException(throwable) + Logger.error(methodTag, "Reply message failed", throwable) + throw throwable + } finally { + span.end() // Always end the span + } } /** @@ -235,24 +251,4 @@ class PasskeyReplyChannel( type = requestType ) } - - /** - * Sends a message to JavaScript via the reply proxy. - */ - @SuppressLint("RequiresFeature", "Only called when feature is available") - private fun send(message: ReplyMessage) { - val methodTag = "$TAG:send" - try { - replyProxy.postMessage(message.toString()) - } catch (throwable: Throwable) { - SpanExtension.current().apply { - setStatus(StatusCode.ERROR) - setAttribute(AttributeName.passkey_operation_type.name, requestType) - recordException(throwable) - end() - } - Logger.error(methodTag, "Reply message failed", throwable) - throw throwable - } - } } diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt index 89727a6d1d..ee4dc1b174 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt @@ -36,11 +36,7 @@ import androidx.webkit.WebViewFeature import com.microsoft.identity.common.BuildConfig import com.microsoft.identity.common.internal.ui.webview.AzureActiveDirectoryWebViewClient import com.microsoft.identity.common.java.exception.ClientException -import com.microsoft.identity.common.java.opentelemetry.OTelUtility -import com.microsoft.identity.common.java.opentelemetry.SpanExtension -import com.microsoft.identity.common.java.opentelemetry.SpanName import com.microsoft.identity.common.logging.Logger -import io.opentelemetry.api.trace.Span import io.opentelemetry.api.trace.SpanContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -60,12 +56,10 @@ import java.util.concurrent.atomic.AtomicBoolean class PasskeyWebListener( private val coroutineScope: CoroutineScope, private val credentialManagerHandler: CredentialManagerHandler, - spanContext: SpanContext? = null + private val spanContext: SpanContext? = null ) : WebViewCompat.WebMessageListener { - val span: Span by lazy { - OTelUtility.createSpanFromParent(SpanName.PasskeyWebListener.name, spanContext) - } + /** Tracks if a WebAuthN request is currently pending. Only one request is allowed at a time. */ private val havePendingRequest = AtomicBoolean(false) @@ -87,7 +81,6 @@ class PasskeyWebListener( isMainFrame: Boolean, replyProxy: JavaScriptReplyProxy, ) { - SpanExtension.makeCurrentSpan(span) parseMessage(message.data, replyProxy)?.let { webAuthNMessage -> onRequest( webAuthNMessage = webAuthNMessage, @@ -117,18 +110,32 @@ class PasskeyWebListener( methodTag, "Received WebAuthN request of type: ${webAuthNMessage.type} from origin: $sourceOrigin" ) - val passkeyReplyChannel = PasskeyReplyChannel(javaScriptReplyProxy, webAuthNMessage.type) + val passkeyReplyChannel = PasskeyReplyChannel( + replyProxy = javaScriptReplyProxy, + requestType = webAuthNMessage.type, + spanContext = spanContext + ) // Only allow one request at a time. if (havePendingRequest.get()) { - passkeyReplyChannel.postError("Request already in progress") + passkeyReplyChannel.postError( + ClientException( + ClientException.REQUEST_IN_PROGRESS, + "A WebAuthN request is already in progress." + ) + ) return } havePendingRequest.set(true) // Only allow requests from the main frame. if (!isMainFrame) { - passkeyReplyChannel.postError("Requests from iframes are not supported") + passkeyReplyChannel.postError( + ClientException( + ClientException.UNSUPPORTED_OPERATION, + "WebAuthN requests from iframes are not supported." + ) + ) havePendingRequest.set(false) return } @@ -154,7 +161,12 @@ class PasskeyWebListener( } else -> { - passkeyReplyChannel.postError("Unknown request type: ${webAuthNMessage.type}") + passkeyReplyChannel.postError( + ClientException( + ClientException.UNSUPPORTED_OPERATION, + "Unsupported WebAuthN request type: ${webAuthNMessage.type}" + ) + ) havePendingRequest.set(false) } } @@ -178,7 +190,12 @@ class PasskeyWebListener( if (publicKeyCredential != null) { reply.postSuccess(publicKeyCredential.authenticationResponseJson) } else { - reply.postError("Unexpected credential type: ${credentialResponse.credential.javaClass.name}") + reply.postError( + ClientException( + ClientException.UNSUPPORTED_OPERATION, + "Retrieved credential is not a PublicKeyCredential." + ) + ) } } .onFailure { throwable -> @@ -220,7 +237,10 @@ class PasskeyWebListener( messageData: String?, javaScriptReplyProxy: JavaScriptReplyProxy ): WebAuthNMessage? { - val passkeyReplyChannel = PasskeyReplyChannel(javaScriptReplyProxy) + val passkeyReplyChannel = PasskeyReplyChannel( + replyProxy = javaScriptReplyProxy, + spanContext = spanContext + ) return runCatching { if (messageData.isNullOrBlank()) { throw ClientException( diff --git a/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt b/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt index 36af7b4707..92767d9343 100644 --- a/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt +++ b/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt @@ -27,6 +27,10 @@ import androidx.credentials.exceptions.CreateCredentialCancellationException import androidx.credentials.exceptions.CreateCredentialInterruptedException import androidx.credentials.exceptions.CreateCredentialProviderConfigurationException import androidx.credentials.exceptions.CreateCredentialUnknownException +import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.GetCredentialInterruptedException +import androidx.credentials.exceptions.GetCredentialProviderConfigurationException +import androidx.credentials.exceptions.GetCredentialUnknownException import androidx.credentials.exceptions.NoCredentialException import androidx.webkit.JavaScriptReplyProxy import io.mockk.mockk @@ -94,7 +98,7 @@ class PasskeyReplyChannelTest { val messageSlot = slot() // When - passkeyReplyChannel.postError(errorMessage) + passkeyReplyChannel.postError(RuntimeException(errorMessage)) // Then verify { mockReplyProxy.postMessage(capture(messageSlot)) } @@ -108,7 +112,7 @@ class PasskeyReplyChannelTest { } @Test - fun `postError with cancellation exception returns NotAllowedError`() { + fun `postError with CreateCredentialCancellationException returns NotAllowedError`() { // Given val exception = CreateCredentialCancellationException("User cancelled") val messageSlot = slot() @@ -120,12 +124,27 @@ class PasskeyReplyChannelTest { verify { mockReplyProxy.postMessage(capture(messageSlot)) } val dataObject = JSONObject(messageSlot.captured).getJSONObject(PasskeyReplyChannel.DATA_KEY) - assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_NOT_ALLOWED_ERROR, - dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) + assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_NOT_ALLOWED_ERROR, dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) + } + + @Test + fun `postError with GetCredentialCancellationException returns NotAllowedError`() { + // Given + val exception = GetCredentialCancellationException("User cancelled") + val messageSlot = slot() + + // When + passkeyReplyChannel.postError(exception) + + // Then + verify { mockReplyProxy.postMessage(capture(messageSlot)) } + + val dataObject = JSONObject(messageSlot.captured).getJSONObject(PasskeyReplyChannel.DATA_KEY) + assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_NOT_ALLOWED_ERROR, dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) } @Test - fun `postError with interruption exception returns AbortError`() { + fun `postError with CreateCredentialInterruptedException returns AbortError`() { // Given val exception = CreateCredentialInterruptedException("Interrupted") val messageSlot = slot() @@ -137,12 +156,27 @@ class PasskeyReplyChannelTest { verify { mockReplyProxy.postMessage(capture(messageSlot)) } val dataObject = JSONObject(messageSlot.captured).getJSONObject(PasskeyReplyChannel.DATA_KEY) - assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_ABORT_ERROR, - dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) + assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_ABORT_ERROR, dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) } @Test - fun `postError with configuration exception returns NotSupportedError`() { + fun `postError with GetCredentialInterruptedException returns AbortError`() { + // Given + val exception = GetCredentialInterruptedException("Interrupted") + val messageSlot = slot() + + // When + passkeyReplyChannel.postError(exception) + + // Then + verify { mockReplyProxy.postMessage(capture(messageSlot)) } + + val dataObject = JSONObject(messageSlot.captured).getJSONObject(PasskeyReplyChannel.DATA_KEY) + assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_ABORT_ERROR, dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) + } + + @Test + fun `postError with CreateCredentialProviderConfigurationException returns NotSupportedError`() { // Given val exception = CreateCredentialProviderConfigurationException("Config missing") val messageSlot = slot() @@ -154,12 +188,27 @@ class PasskeyReplyChannelTest { verify { mockReplyProxy.postMessage(capture(messageSlot)) } val dataObject = JSONObject(messageSlot.captured).getJSONObject(PasskeyReplyChannel.DATA_KEY) - assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_NOT_SUPPORTED_ERROR, - dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) + assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_NOT_SUPPORTED_ERROR, dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) } @Test - fun `postError with unknown exception returns UnknownError`() { + fun `postError with GetCredentialProviderConfigurationException returns NotSupportedError`() { + // Given + val exception = GetCredentialProviderConfigurationException("Config missing") + val messageSlot = slot() + + // When + passkeyReplyChannel.postError(exception) + + // Then + verify { mockReplyProxy.postMessage(capture(messageSlot)) } + + val dataObject = JSONObject(messageSlot.captured).getJSONObject(PasskeyReplyChannel.DATA_KEY) + assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_NOT_SUPPORTED_ERROR, dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) + } + + @Test + fun `postError with CreateCredentialUnknownException returns UnknownError`() { // Given val exception = CreateCredentialUnknownException("Unknown error") val messageSlot = slot() @@ -171,8 +220,23 @@ class PasskeyReplyChannelTest { verify { mockReplyProxy.postMessage(capture(messageSlot)) } val dataObject = JSONObject(messageSlot.captured).getJSONObject(PasskeyReplyChannel.DATA_KEY) - assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_UNKNOWN_ERROR, - dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) + assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_UNKNOWN_ERROR, dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) + } + + @Test + fun `postError with GetCredentialUnknownException returns UnknownError`() { + // Given + val exception = GetCredentialUnknownException("Unknown error") + val messageSlot = slot() + + // When + passkeyReplyChannel.postError(exception) + + // Then + verify { mockReplyProxy.postMessage(capture(messageSlot)) } + + val dataObject = JSONObject(messageSlot.captured).getJSONObject(PasskeyReplyChannel.DATA_KEY) + assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_UNKNOWN_ERROR, dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) } @Test @@ -188,8 +252,7 @@ class PasskeyReplyChannelTest { verify { mockReplyProxy.postMessage(capture(messageSlot)) } val dataObject = JSONObject(messageSlot.captured).getJSONObject(PasskeyReplyChannel.DATA_KEY) - assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_NOT_ALLOWED_ERROR, - dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) + assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_NOT_ALLOWED_ERROR, dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) } @Test @@ -205,14 +268,13 @@ class PasskeyReplyChannelTest { verify { mockReplyProxy.postMessage(capture(messageSlot)) } val dataObject = JSONObject(messageSlot.captured).getJSONObject(PasskeyReplyChannel.DATA_KEY) - assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_NOT_ALLOWED_ERROR, - dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) + assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_NOT_ALLOWED_ERROR, dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) } @Test fun `postError handles null exception message`() { // Given - val exception = RuntimeException(null as String?) + val exception = RuntimeException(null as Throwable?) val messageSlot = slot() // When @@ -222,8 +284,7 @@ class PasskeyReplyChannelTest { verify { mockReplyProxy.postMessage(capture(messageSlot)) } val dataObject = JSONObject(messageSlot.captured).getJSONObject(PasskeyReplyChannel.DATA_KEY) - assertEquals("Unknown error (empty message)", - dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_MESSAGE_KEY)) + assertEquals("Unknown error (empty message)", dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_MESSAGE_KEY)) } @Test diff --git a/common4j/src/main/com/microsoft/identity/common/java/exception/ClientException.java b/common4j/src/main/com/microsoft/identity/common/java/exception/ClientException.java index 8397e5241f..22b0131efb 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/exception/ClientException.java +++ b/common4j/src/main/com/microsoft/identity/common/java/exception/ClientException.java @@ -133,6 +133,16 @@ public class ClientException extends BaseException { */ public static final String UNSUPPORTED_ENCODING = "unsupported_encoding"; + /** + * The operation is not supported. + */ + public static final String UNSUPPORTED_OPERATION = "unsupported_operation"; + + /** + * The request is already in progress. + */ + public static final String REQUEST_IN_PROGRESS = "request_in_progress"; + /** * The designated crypto alg is not supported. */ diff --git a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java index 36fb96d825..ec86d11b18 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java +++ b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java @@ -492,5 +492,10 @@ public enum AttributeName { /** * Passkey operation type (e.g., registration, authentication). */ - passkey_operation_type + passkey_operation_type, + + /** + * Passkey DOM exception name (if any). + */ + passkey_dom_exception_name, } From 475e918da1ba05ce2c9beb4d5c590a33714ac478 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 6 Nov 2025 11:26:37 -0800 Subject: [PATCH 37/44] Add JsScriptRecord validation for sovereign cloud URLs and implement unit tests --- .../oauth2/CredentialManagerHandler.kt | 5 ++ .../oauth2/WebViewAuthorizationFragment.java | 28 ------- .../internal/ui/webview/JsScriptRecord.kt | 28 +++++-- .../internal/ui/webview/JsScriptRecordTest.kt | 84 +++++++++++++++++++ 4 files changed, 110 insertions(+), 35 deletions(-) create mode 100644 common/src/test/java/com/microsoft/identity/common/internal/ui/webview/JsScriptRecordTest.kt diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt index 22dc4998e2..5f5f5775f2 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt @@ -32,6 +32,11 @@ import androidx.credentials.GetCredentialResponse import androidx.credentials.GetPublicKeyCredentialOption import com.microsoft.identity.common.logging.Logger +/** + * Handler class to encapsulate Credential Manager APIs for passkey operations. + * + * @property activity The activity context used for credential operations. + */ class CredentialManagerHandler(private val activity: Activity) { companion object { diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java index a59f38ced6..794089f4d7 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java @@ -44,7 +44,6 @@ import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; -import android.webkit.ConsoleMessage; import android.webkit.PermissionRequest; import android.webkit.WebChromeClient; import android.webkit.WebSettings; @@ -359,33 +358,6 @@ public Bitmap getDefaultVideoPoster() { // We will return a 10x10 empty image, instead of the default grey playback image. #2424 return Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888); } - - /** - * Capture console messages and log them using our logger. - * - * @param consoleMessage The console message from the WebView - * @return returns true if we handled the message. False will let the default handler handle it. - */ - @Override - public boolean onConsoleMessage(@NonNull final ConsoleMessage consoleMessage) { - // Note: Decide what we are interested in logging and what level. - super.onConsoleMessage(consoleMessage); - final String methodTag = TAG + ":onConsoleMessage"; - final String errorMessage = consoleMessage.message() + " -- From line " + - consoleMessage.lineNumber() + " of " + consoleMessage.sourceId(); - switch (consoleMessage.messageLevel()) { - case ERROR: - Logger.errorPII(methodTag, errorMessage, null); - break; - case WARNING: - Logger.warnPII(methodTag, errorMessage); - break; - default: - Logger.infoPII(methodTag, errorMessage); - } - return true; - } - }); setupPasskeyWebListener(mWebView, webViewClient); } diff --git a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/JsScriptRecord.kt b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/JsScriptRecord.kt index 60ba117b79..d5a16ddbdb 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/JsScriptRecord.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/JsScriptRecord.kt @@ -22,6 +22,8 @@ // THE SOFTWARE. package com.microsoft.identity.common.internal.ui.webview +import androidx.core.net.toUri + /** * Record representing a JavaScript script to be injected into a WebView, along with metadata * about the script. @@ -51,7 +53,8 @@ class JsScriptRecord( * * A script is considered allowed if: * - [allowedUrls] is `null`, meaning no restrictions. - * - The provided [url] starts with any of the prefixes in [allowedUrls]. + * - The provided [url] matches the scheme, host, and port of an allowed URL prefix. + * Path validation is applied for sovereign cloud URLs. * * @param url The URL to check against the allowed list. * @return `true` if the script can execute for this URL, `false` otherwise. @@ -60,14 +63,25 @@ class JsScriptRecord( // No restrictions — allowed for any URL if (allowedUrls == null) return true - // Check if the URL starts with any allowed prefix - return allowedUrls.any { prefix -> - if (SOVEREIGN_CLOUD_URL_WITH_EXTRA_VALIDATION.contains(prefix) && url.startsWith(prefix)) { + val uri = url.toUri() + + // Check if the URL matches any allowed prefix + return allowedUrls.any { allowedUrl -> + val allowedUri = allowedUrl.toUri() + + // Match scheme, host, and port to prevent subdomain spoofing + val schemeMatches = uri.scheme == allowedUri.scheme + val hostMatches = uri.host == allowedUri.host + + if (schemeMatches && hostMatches) { // For sovereign cloud URLs, require 'fido' in the path - val path = url.removePrefix(prefix) - path.contains("fido", ignoreCase = true) + if (SOVEREIGN_CLOUD_URL_WITH_EXTRA_VALIDATION.contains(allowedUrl)) { + uri.path?.contains("fido", ignoreCase = true) == true + } else { + true + } } else { - url.startsWith(prefix) + false } } } diff --git a/common/src/test/java/com/microsoft/identity/common/internal/ui/webview/JsScriptRecordTest.kt b/common/src/test/java/com/microsoft/identity/common/internal/ui/webview/JsScriptRecordTest.kt new file mode 100644 index 0000000000..11ddea16f4 --- /dev/null +++ b/common/src/test/java/com/microsoft/identity/common/internal/ui/webview/JsScriptRecordTest.kt @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.internal.ui.webview + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class JsScriptRecordTest { + + @Test + fun `isAllowedForUrl returns true when allowedUrls is null`() { + val record = JsScriptRecord("id", "script", null) + assertTrue(record.isAllowedForUrl("https://any.url.com")) + } + + @Test + fun `isAllowedForUrl returns true for exact allowed prefix`() { + val allowed = setOf("https://example.com") + val record = JsScriptRecord("id", "script", allowed) + assertTrue(record.isAllowedForUrl("https://example.com/page")) + } + + @Test + fun `isAllowedForUrl returns false for non-matching prefix`() { + val allowed = setOf("https://example.com") + val record = JsScriptRecord("id", "script", allowed) + assertFalse(record.isAllowedForUrl("https://other.com/page")) + } + + @Test + fun `isAllowedForUrl returns true for sovereign cloud with fido in path`() { + val allowed = setOf("https://login.microsoftonline.us") + val record = JsScriptRecord("id", "script", allowed) + assertTrue(record.isAllowedForUrl("https://login.microsoftonline.us/fido/endpoint")) + assertTrue(record.isAllowedForUrl("https://login.microsoftonline.us/some/path/fido")) + } + + @Test + fun `isAllowedForUrl returns false for sovereign cloud without fido in path`() { + val allowed = setOf("https://login.microsoftonline.us") + val record = JsScriptRecord("id", "script", allowed) + assertFalse(record.isAllowedForUrl("https://login.microsoftonline.us/other/endpoint")) + } + + @Test + fun `isAllowedForUrl returns false for sovereign cloud subdomain`() { + val allowed = setOf("https://login.microsoftonline.us") + val record = JsScriptRecord("id", "script", allowed) + // Should not match, as it's a subdomain, not a path + assertFalse(record.isAllowedForUrl("https://login.microsoftonline.us.someDomain.com/fido")) + } + + @Test + fun `isAllowedForUrl returns true for non-sovereign allowed prefix`() { + val allowed = setOf("https://mytenant.b2clogin.com") + val record = JsScriptRecord("id", "script", allowed) + assertTrue(record.isAllowedForUrl("https://mytenant.b2clogin.com/path")) + } +} + From 0609a465b8596a18221f9c2aaff0c0928a010e84 Mon Sep 17 00:00:00 2001 From: pedro romero vargas <76129899+p3dr0rv@users.noreply.github.com> Date: Thu, 6 Nov 2025 12:27:35 -0800 Subject: [PATCH 38/44] Update common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../internal/ui/webview/AzureActiveDirectoryWebViewClient.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java index df5595dcb7..8b895d966d 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java @@ -1211,7 +1211,7 @@ private String getBrokerAppPackageNameFromUrl(@NonNull final String url) { * @return Created {@link Span} */ private Span createSpanWithAttributesFromParent(@NonNull final String spanName) { - final Span span = OTelUtility.createSpanFromParent(spanName, mSpanContext); + final Span span = OTelUtility.createSpanFromParent(spanName, mSpanContext); if (mUtid != null) { span.setAttribute(AttributeName.tenant_id.name(), mUtid); } From bb3957e87d6a74b7f3bebe210cd1d4ff45503b03 Mon Sep 17 00:00:00 2001 From: pedro romero vargas <76129899+p3dr0rv@users.noreply.github.com> Date: Thu, 6 Nov 2025 12:28:57 -0800 Subject: [PATCH 39/44] Update common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../ui/webview/AzureActiveDirectoryWebViewClient.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java index 8b895d966d..2566db3f4a 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java @@ -1249,6 +1249,12 @@ public void addOnPageStartedScript( ); } + /** + * Returns the span context associated with this WebView client. + * The span context is used for creating child spans to maintain telemetry hierarchy. + * + * @return the {@link SpanContext} if available; may be null if the associated activity is not an {@link AuthorizationActivity}. + */ public SpanContext getSpanContext() { return mSpanContext; } From 00b8a82c01e001885a4ca61e05071ae8547d2ec4 Mon Sep 17 00:00:00 2001 From: pedro romero vargas <76129899+p3dr0rv@users.noreply.github.com> Date: Thu, 6 Nov 2025 12:30:39 -0800 Subject: [PATCH 40/44] Update common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../common/internal/providers/oauth2/PasskeyWebListener.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt index ee4dc1b174..de8a833c8b 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt @@ -59,8 +59,6 @@ class PasskeyWebListener( private val spanContext: SpanContext? = null ) : WebViewCompat.WebMessageListener { - - /** Tracks if a WebAuthN request is currently pending. Only one request is allowed at a time. */ private val havePendingRequest = AtomicBoolean(false) From 827c26fb8367b946844d0e200177a252f113e59f Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 6 Nov 2025 12:47:50 -0800 Subject: [PATCH 41/44] Refactor PasskeyReplyChannel error handling and logging; remove redundant tests from PasskeyReplyChannelTest --- .../providers/oauth2/PasskeyReplyChannel.kt | 15 +++-- .../AzureActiveDirectoryWebViewClient.java | 3 +- .../oauth2/PasskeyReplyChannelTest.kt | 62 ------------------- 3 files changed, 9 insertions(+), 71 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt index adcd5b85e4..43ca0d9a8c 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt @@ -154,8 +154,8 @@ class PasskeyReplyChannel( val successMessage = ReplyMessage.Success(json, requestType).toString() replyProxy.postMessage(successMessage) Logger.info(methodTag, "RequestType: $requestType was successful.") - span.setStatus(StatusCode.OK) span.setAttribute(AttributeName.passkey_operation_type.name, requestType) + span.setStatus(StatusCode.OK) } } catch (throwable: Throwable) { span.setStatus(StatusCode.ERROR) @@ -190,19 +190,18 @@ class PasskeyReplyChannel( SpanExtension.makeCurrentSpan(span).use { val errorMessage = throwableToErrorMessage(throwable) replyProxy.postMessage(errorMessage.toString()) - Logger.error(methodTag, "RequestType: $requestType failed with error: $errorMessage", null) span.setAttribute(AttributeName.passkey_operation_type.name, requestType) span.setAttribute(AttributeName.passkey_dom_exception_name.name, errorMessage.domExceptionName) span.setStatus(StatusCode.ERROR) span.recordException(throwable) + Logger.error(methodTag, "RequestType: $requestType failed with error: $errorMessage", null) } - } catch (throwable: Throwable) { - // Handle exception safely, no scope needed - span.setAttribute(AttributeName.passkey_operation_type.name, requestType) + } catch (unexpectedException: Throwable) { span.setStatus(StatusCode.ERROR) - span.recordException(throwable) - Logger.error(methodTag, "Reply message failed", throwable) - throw throwable + span.recordException(unexpectedException) + span.setAttribute(AttributeName.passkey_operation_type.name, requestType) + Logger.error(methodTag, "Reply message failed", unexpectedException) + throw unexpectedException } finally { span.end() // Always end the span } diff --git a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java index 2566db3f4a..76c91cc6a1 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java @@ -1064,7 +1064,7 @@ private void processWebCpAuthorize(@NonNull final WebView view, @NonNull final S private void processCrossCloudRedirect(@NonNull final WebView view, @NonNull final String url) { final String methodTag = TAG + ":processCrossCloudRedirect"; - final Span span = OTelUtility.createSpanFromParent(SpanName.ProcessCrossCloudRedirect.name(), mSpanContext); + final Span span = OTelUtility.createSpanFromParent(SpanName.ProcessCrossCloudRedirect.name(), mSpanContext); final ReAttachPrtHeaderHandler reAttachPrtHeaderHandler = new ReAttachPrtHeaderHandler(view, mRequestHeaders, span); reAttachPrtHeader(url, reAttachPrtHeaderHandler, view, methodTag, span); } @@ -1255,6 +1255,7 @@ public void addOnPageStartedScript( * * @return the {@link SpanContext} if available; may be null if the associated activity is not an {@link AuthorizationActivity}. */ + @Nullable public SpanContext getSpanContext() { return mSpanContext; } diff --git a/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt b/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt index 92767d9343..0e4f93766b 100644 --- a/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt +++ b/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt @@ -35,7 +35,6 @@ import androidx.credentials.exceptions.NoCredentialException import androidx.webkit.JavaScriptReplyProxy import io.mockk.mockk import io.mockk.verify -import io.mockk.every import io.mockk.slot import org.json.JSONObject import org.junit.Assert.* @@ -271,38 +270,6 @@ class PasskeyReplyChannelTest { assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_NOT_ALLOWED_ERROR, dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) } - @Test - fun `postError handles null exception message`() { - // Given - val exception = RuntimeException(null as Throwable?) - val messageSlot = slot() - - // When - passkeyReplyChannel.postError(exception) - - // Then - verify { mockReplyProxy.postMessage(capture(messageSlot)) } - - val dataObject = JSONObject(messageSlot.captured).getJSONObject(PasskeyReplyChannel.DATA_KEY) - assertEquals("Unknown error (empty message)", dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_MESSAGE_KEY)) - } - - @Test - fun `send throws exception when postMessage fails`() { - // Given - val testJson = """{"key": "value"}""" - val expectedException = RuntimeException("PostMessage failed") - every { mockReplyProxy.postMessage(any()) } throws expectedException - - // When/Then - Should throw the exception - val thrownException = assertThrows(RuntimeException::class.java) { - passkeyReplyChannel.postSuccess(testJson) - } - - assertEquals("PostMessage failed", thrownException.message) - verify { mockReplyProxy.postMessage(any()) } - } - @Test fun `constructor uses unknown as default request type`() { // Given @@ -319,34 +286,5 @@ class PasskeyReplyChannelTest { assertEquals("unknown", messageObject.getString(PasskeyReplyChannel.TYPE_KEY)) } - @Test - fun `ReplyMessage Success handles complex JSON structures`() { - // Given - val complexJson = """{"nested": {"array": [1, 2, 3], "boolean": true}, "string": "test"}""" - val successMessage = PasskeyReplyChannel.ReplyMessage.Success(complexJson, testRequestType) - - // When - val result = successMessage.toString() - - // Then - val messageObject = JSONObject(result) - assertEquals(PasskeyReplyChannel.SUCCESS_STATUS, messageObject.getString(PasskeyReplyChannel.STATUS_KEY)) - - val dataObject = messageObject.getJSONObject(PasskeyReplyChannel.DATA_KEY) - assertEquals("test", dataObject.getString("string")) - assertEquals(true, dataObject.getJSONObject("nested").getBoolean("boolean")) - } - - @Test - fun `constants have correct values`() { - assertEquals("success", PasskeyReplyChannel.SUCCESS_STATUS) - assertEquals("error", PasskeyReplyChannel.ERROR_STATUS) - assertEquals("PasskeyReplyChannel", PasskeyReplyChannel.TAG) - - assertEquals("NotAllowedError", PasskeyReplyChannel.DOM_EXCEPTION_NOT_ALLOWED_ERROR) - assertEquals("AbortError", PasskeyReplyChannel.DOM_EXCEPTION_ABORT_ERROR) - assertEquals("NotSupportedError", PasskeyReplyChannel.DOM_EXCEPTION_NOT_SUPPORTED_ERROR) - assertEquals("UnknownError", PasskeyReplyChannel.DOM_EXCEPTION_UNKNOWN_ERROR) - } } From 55238fdc1f49a4203bee5fa5ffe0d64b482a9cec Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 6 Nov 2025 14:17:39 -0800 Subject: [PATCH 42/44] Refactor PasskeyWebListener error handling to use ClientException for better clarity; remove redundant test from PasskeyReplyChannelTest --- .../providers/oauth2/PasskeyWebListener.kt | 29 +++++++++++++++---- .../AzureActiveDirectoryWebViewClient.java | 3 +- .../oauth2/PasskeyReplyChannelTest.kt | 20 ------------- 3 files changed, 25 insertions(+), 27 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt index b5db958a2c..7623467b13 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt @@ -110,14 +110,24 @@ class PasskeyWebListener( // Only allow one request at a time. if (havePendingRequest.get()) { - passkeyReplyChannel.postError("Request already in progress") + passkeyReplyChannel.postError( + ClientException( + ClientException.REQUEST_IN_PROGRESS, + "A WebAuthN request is already in progress." + ) + ) return } havePendingRequest.set(true) // Only allow requests from the main frame. if (!isMainFrame) { - passkeyReplyChannel.postError("Requests from iframes are not supported") + passkeyReplyChannel.postError( + ClientException( + ClientException.UNSUPPORTED_OPERATION, + "WebAuthN requests from iframes are not supported." + ) + ) havePendingRequest.set(false) return } @@ -143,7 +153,12 @@ class PasskeyWebListener( } else -> { - passkeyReplyChannel.postError("Unknown request type: ${webAuthNMessage.type}") + passkeyReplyChannel.postError( + ClientException( + ClientException.UNSUPPORTED_OPERATION, + "Unsupported WebAuthN request type: ${webAuthNMessage.type}" + ) + ) havePendingRequest.set(false) } } @@ -167,8 +182,12 @@ class PasskeyWebListener( if (publicKeyCredential != null) { reply.postSuccess(publicKeyCredential.authenticationResponseJson) } else { - reply.postError("Unexpected credential type: ${credentialResponse.credential.javaClass.name}") - } + reply.postError( + ClientException( + ClientException.UNSUPPORTED_OPERATION, + "Retrieved credential is not a PublicKeyCredential." + ) + ) } } .onFailure { throwable -> reply.postError(throwable) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java index 99996820d4..18f70d4c37 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java @@ -141,8 +141,7 @@ public class AzureActiveDirectoryWebViewClient extends OAuth2WebViewClient { private boolean mAuthUxJavaScriptInterfaceAdded = false; // Determines whether to handle WebCP requests in the WebView in brokerless scenarios. private final boolean mIsWebViewWebCpEnabledInBrokerlessCase; - - + private final SpanContext mSpanContext; private final String mUtid; private final List mOnPageStartedScripts = new ArrayList<>(); diff --git a/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt b/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt index 36af7b4707..6dbb2196cb 100644 --- a/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt +++ b/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt @@ -87,26 +87,6 @@ class PasskeyReplyChannelTest { assertEquals(0, dataObject.length()) } - @Test - fun `postError with string sends correct error format`() { - // Given - val errorMessage = "Test error message" - val messageSlot = slot() - - // When - passkeyReplyChannel.postError(errorMessage) - - // Then - verify { mockReplyProxy.postMessage(capture(messageSlot)) } - - val messageObject = JSONObject(messageSlot.captured) - val dataObject = messageObject.getJSONObject(PasskeyReplyChannel.DATA_KEY) - - assertEquals(errorMessage, dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_MESSAGE_KEY)) - assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_NOT_ALLOWED_ERROR, - dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) - } - @Test fun `postError with cancellation exception returns NotAllowedError`() { // Given From 9260512e35d47e009a1d8756479c5648ddc96e15 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 6 Nov 2025 15:04:57 -0800 Subject: [PATCH 43/44] Update error message assertion in PasskeyWebListenerTest for clarity on unsupported request types --- .../common/internal/providers/oauth2/PasskeyWebListenerTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListenerTest.kt b/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListenerTest.kt index 75bdb5171b..a373f4fe50 100644 --- a/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListenerTest.kt +++ b/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListenerTest.kt @@ -304,7 +304,7 @@ class PasskeyWebListenerTest { val responseObject = JSONObject(messageSlot.captured) assertEquals(PasskeyReplyChannel.ERROR_STATUS, responseObject.getString(PasskeyReplyChannel.STATUS_KEY)) val dataObject = responseObject.getJSONObject(PasskeyReplyChannel.DATA_KEY) - assertTrue(dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_MESSAGE_KEY).contains("Unknown request type")) + assertTrue(dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_MESSAGE_KEY).contains("Unsupported WebAuthN request type: unknown_type")) } // ========== Frame Origin Tests ========== From a04ccc82d4fe336d5a610e621ed148a0c903f38b Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 6 Nov 2025 15:30:44 -0800 Subject: [PATCH 44/44] Update changelog to reflect addition of OpenTelemetry support for passkey operations --- changelog.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.txt b/changelog.txt index 5df56f723d..a957384bb7 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,6 +1,6 @@ vNext ---------- -- [MINOR] Add passkey registration support for WebView (#2769) +- [MINOR] Add OpenTelemetry support for passkey operations (#2795) - [MINOR] Add passkey registration support for WebView (#2769) - [MINOR] Add callback for OneAuth for measuring Broker Discovery Client Perf (#2796) - [MINOR] Add new span name for DELEGATION_CERT_INSTALL's telemetry (#2790)