Skip to content

Expose a JavaScript API in brokered Webviews to facilitate Improved Same Device NumberMatch , Fixes AB#3203956 #2617

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: dev
Choose a base branch
from
1 change: 1 addition & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
vNext
----------
- [MINOR] Expose a JavaScript API in brokered Webviews to facilitate Improved Same Device NumberMatch (#2617)

Version 21.1.0
----------
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// 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.broker

import android.webkit.JavascriptInterface
import com.google.gson.stream.MalformedJsonException
import com.microsoft.identity.common.internal.numberMatch.NumberMatchHelper
import com.microsoft.identity.common.java.util.JsonUtil
import com.microsoft.identity.common.logging.Logger

/**
* JavaScript API to receive JSON string payloads from AuthUX in order to facilitate calling various
* broker methods.
*/
class AuthUxJavaScriptInterface {

// Store number matches in a static hash map
// No need to persist this storage beyond the current broker process, but we need to keep them
// long enough for AuthApp to call the broker api to fetch the number match
companion object {
val TAG = AuthUxJavaScriptInterface::class.java.simpleName
private const val JAVASCRIPT_INTERFACE_NAME = "ClientBrokerJS"

fun getInterfaceName() : String {
return JAVASCRIPT_INTERFACE_NAME
}
}

/**
* Method to receive a JSON string payload from AuthUX through JavaScript API.
* Schema for the Json Payload:
* {
* "correlationID": "SOME_CORRELATION_ID" ,
* "action_name":"write_data",
* "action_component":"broker",
* "params":
* {
* "function": "NUMBER_MATCH",
* "data":
* {
* "sessionID": "$mockSessionId",
* "numberMatch": "$mockNumberMatchValue"
* }
* }
* }
* TODO: This is currently the schema set for numberMatch, there may be some additions made for
* the more generalized JSON Schema for future Server-side to broker communication through JS.
*
* https://microsoft-my.sharepoint-df.com/:w:/p/veenasoman/EY1AZIeT8X5KrXVz97Vx520B3Jj0fBLSPlklnoRvcmbh0Q?e=VzNFd1&ovuser=72f988bf-86f1-41af-91ab-2d7cd011db47%2Cfadidurah%40microsoft.com&clickparams=eyJBcHBOYW1lIjoiVGVhbXMtRGVza3RvcCIsIkFwcFZlcnNpb24iOiI0OS8yNTA1MDQwMTYwOSIsIkhhc0ZlZGVyYXRlZFVzZXIiOmZhbHNlfQ%3D%3D
*/
@JavascriptInterface
fun postMessageToBroker(jsonPayload: String) {
val methodTag = "$TAG:postMessageToBroker"
Logger.info(methodTag, "Received a payload from AuthUX through JavaScript API.")

try {
val parsedJson = JsonUtil.extractJsonObjectIntoMap(jsonPayload)

val correlationID = parsedJson["correlationID"]
Logger.info(methodTag, "Correlation ID during JavaScript Call: [$correlationID]")

// TODO: Leaving these here, as these will be relevant for next WebCP feature
// val actionName = parsedJson["action_name"]
// val actionComponent = parsedJson["action_component"]

val parameters = JsonUtil.extractJsonObjectIntoMap(parsedJson["params"])
val function = parameters["function"]
val data = JsonUtil.extractJsonObjectIntoMap(parameters["data"])
Comment on lines +77 to +88
Copy link
Contributor

@shahzaibj shahzaibj May 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's define a DTO representing the json schema and then you can just use the kotlinx serialization libraries (or even GSON or Moshi if you prefer that) to deserialize the raw json directly to a custom dto.

For instance:

@Serializable
data class AuthUxApiResponse(
    val correlationId: String,
    val actionName: String,
    val actionComponent: String,
    val params: Params; // define this as a DTO as well
)

and then you can deserialize as follows using kotlinx serialization framework

val authUxResponse: AuthUxApiResponse = Json.decodeFromString(jsonString)

and then you can access individual properties as follows:

   val numberMatch = authUxResponse.params.data.numberMatch;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neat! I can adjust to this, this current Json parsing implementation is a bit janky

Logger.info(methodTag, "Function name: [$function]")

when (function) {
FunctionNames.NUMBER_MATCH.name ->
NumberMatchHelper.storeNumberMatch(
data[NumberMatchHelper.SESSION_ID_ATTRIBUTE_NAME],
data[NumberMatchHelper.NUMBER_MATCH_ATTRIBUTE_NAME])
else ->
Logger.warn(methodTag, "Payload from AuthUX contained an unknown function name.")
}
} catch (e: Exception) { // If we run into exceptions, we don't want to kill the broker
when (e) {
is NullPointerException -> {
Logger.warn(methodTag, "Payload with missing mandatory fields sent through JavaScriptInterface")
}
is MalformedJsonException -> {
Logger.warn(methodTag, "Malformed JSON payload sent through JavaScriptInterface")
}
else -> {
Logger.warn(methodTag, "Unknown error occurred while processing the payload.")
}
}
}
}

/**
* Enum class to hold function names
*/
enum class FunctionNames {
NUMBER_MATCH
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// 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.numberMatch

import com.microsoft.identity.common.logging.Logger

/**
* Helper to facilitate NumberMatchFlow. Used in conjunction with {@link AuthUxJavaScriptInterface}
* When authenticator is installed, and phone uses MFA or PSI in an interactive flow, a number
* matching challenge is issued, where used is given a number and asked to open authenticator and check
* for the same number in authenticator UI. This feature cuts out one UI step, where this API is used to
* supply the number match value and store it in ephemeral storage (kept as long as current broker
* process is alive), where Authenticator can call a broker API to fetch the number match, and immediately
* prompt user for consent, rather than first asking them to check the number match.
*/
class NumberMatchHelper {

// Store number matches in a static hash map
// No need to persist this storage beyond the current broker process, but we need to keep them
// long enough for AuthApp to call the broker api to fetch the number match
companion object {
val TAG = NumberMatchHelper::class.java.simpleName
val numberMatchMap: HashMap<String, String> = HashMap()
const val SESSION_ID_ATTRIBUTE_NAME = "sessionID"
const val NUMBER_MATCH_ATTRIBUTE_NAME = "numberMatch"

/**
* Method to add a key:value pair of sessionID:numberMatch to static hashmap. This hashmap will be accessed
* by broker api to get the number match for a particular sessionID.
*/
fun storeNumberMatch(sessionId: String?, numberMatch: String?) {
val methodTag = "$TAG:storeNumberMatch"
Logger.info(methodTag,
"Adding entry in NumberMatch hashmap for session ID: $sessionId")

// If both parameters are non-null, add a new entry to the hashmap
if (sessionId != null && numberMatch != null) {
numberMatchMap[sessionId] = numberMatch
}
// If either parameter is null, do nothing
else {
Logger.warn(methodTag,
"Either session ID or number match is null. Nothing to add for number match."
)
}
}

/**
* Clear existing number match key:value pairs
*/
fun clearNumberMatchMap() {
numberMatchMap.clear()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import android.view.View;
import android.view.ViewGroup;
import android.webkit.PermissionRequest;
import android.webkit.ValueCallback;
import android.webkit.WebChromeClient;
import android.webkit.WebSettings;
import android.webkit.WebView;
Expand All @@ -47,7 +48,9 @@
import com.microsoft.identity.common.R;
import com.microsoft.identity.common.internal.fido.LegacyFidoActivityResultContract;
import com.microsoft.identity.common.internal.fido.LegacyFido2ApiObject;
import com.microsoft.identity.common.internal.broker.AuthUxJavaScriptInterface;
import com.microsoft.identity.common.internal.ui.webview.ISendResultCallback;
import com.microsoft.identity.common.internal.ui.webview.ProcessUtil;
import com.microsoft.identity.common.internal.ui.webview.switchbrowser.SwitchBrowserProtocolCoordinator;
import com.microsoft.identity.common.java.WarningType;
import com.microsoft.identity.common.adal.internal.AuthenticationConstants;
Expand Down Expand Up @@ -122,6 +125,9 @@ public class WebViewAuthorizationFragment extends AuthorizationFragment {
// This is used by the switch browser protocol to handle the resume of the flow.
private SwitchBrowserProtocolCoordinator mSwitchBrowserProtocolCoordinator = null;

private boolean isBrokerRequest = false;
private boolean isEstsRequest = false;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH, I don't think we make requests anywhere besides eSTS so all of our requests are to eSTS. So not sure what this check is doing

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May be you intend to check for the "current" URL that we are navigating to within the webview? I think that would be ideal but I don't think your code is doing that at the moment


@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Expand Down Expand Up @@ -211,6 +217,12 @@ void extractState(@NonNull final Bundle state) {
mAuthIntent = state.getParcelable(AUTH_INTENT);
mPkeyAuthStatus = state.getBoolean(PKEYAUTH_STATUS, false);
mAuthorizationRequestUrl = state.getString(REQUEST_URL);
if (mAuthorizationRequestUrl != null) {
isEstsRequest = mAuthorizationRequestUrl.startsWith("https://login.microsoftonline.com");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about sovereign clouds?

}
if (getContext() != null) {
isBrokerRequest = ProcessUtil.isRunningOnAuthService(getContext());
}
mRedirectUri = state.getString(REDIRECT_URI);
mRequestHeaders = getRequestHeaders(state);
mPostPageLoadedJavascript = state.getString(POST_PAGE_LOADED_URL);
Expand Down Expand Up @@ -290,6 +302,9 @@ private void setUpWebView(@NonNull final View view,
mWebView.getSettings().setUserAgentString(
userAgent + AuthenticationConstants.Broker.CLIENT_TLS_NOT_SUPPORTED);
mWebView.getSettings().setJavaScriptEnabled(true);
if (isBrokerRequest && isEstsRequest) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens once the URL changes within the webview? So we go from eSTS to ADFS for federated auth but the isEstsRequest would still say true because it was defined earlier in the flow. This would be a problem

mWebView.addJavascriptInterface(new AuthUxJavaScriptInterface(), AuthUxJavaScriptInterface.Companion.getInterfaceName());
}
mWebView.requestFocus(View.FOCUS_DOWN);

// Set focus to the view for touch event
Expand Down Expand Up @@ -398,7 +413,7 @@ private HashMap<String, String> getRequestHeaders(final Bundle state) {
}

// Attach client extras header for ESTS telemetry. Only done for broker requests
if (isBrokerRequest(this.mAuthorizationRequestUrl)) {
if (isBrokerRequest) {
final ClientExtraSku clientExtraSku = ClientExtraSku.builder()
.srcSku(state.getString(PRODUCT))
.srcSkuVer(state.getString(VERSION))
Expand All @@ -415,15 +430,7 @@ private HashMap<String, String> getRequestHeaders(final Bundle state) {
public ActivityResultLauncher<LegacyFido2ApiObject> getFidoLauncher() {
return mFidoLauncher;
}

/**
* Helper method to check if the authorization request is being made through broker.
* Done by checking for broker version key in the url
*/
private boolean isBrokerRequest(final String authorizationUrl) {
return authorizationUrl.contains(Device.PlatformIdParameters.BROKER_VERSION);
}


class AuthorizationCompletionCallback implements IAuthorizationCompletionCallback {
@Override
public void onChallengeResponseReceived(@NonNull final RawAuthorizationResult response) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// 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.broker

import com.microsoft.identity.common.internal.numberMatch.NumberMatchHelper
import org.junit.Before
import org.junit.Test

class AuthUxJavaScriptInterfaceTest {

private lateinit var authUxJavaScriptInterface: AuthUxJavaScriptInterface

private val mockSessionId = "1234"
private val mockNumberMatchValue = "00"

private val numberMatchTestPayload = """
{
"correlationID": "SOME_CORRELATION_ID" ,
"action_name":"write_data",
"action_component":"broker",
"params":
{
"function": "NUMBER_MATCH",
"data":
{
"sessionID": "$mockSessionId",
"numberMatch": "$mockNumberMatchValue"
}
}
}
""".trimIndent()

@Before
fun setUp() {
authUxJavaScriptInterface = AuthUxJavaScriptInterface()
}

@Test
fun `test postMessageToBroker with NUMBER_MATCH function`() {
// Call the method
authUxJavaScriptInterface.postMessageToBroker(numberMatchTestPayload)

// Verify that the data was stored in NumberMatchHelper
val storedValue = NumberMatchHelper.numberMatchMap[mockSessionId]
assert(storedValue == mockNumberMatchValue)
}
}
Loading
Loading