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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
- [MINOR] Add API for resource account provisioning (API only) (#2640)

Version 21.1.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1222,6 +1222,16 @@ public static String computeMaxHostBrokerProtocol() {
*/
public static final String REDIRECT_PREFIX = "msauth";

/**
* Prefix for AAD urls
*/
public static final String AAD_URL_HOST_PREFIX = "login.microsoftonline.";

/**
* Prefix for MSA urls
*/
public static final String MSA_URL_HOST_PREFIX = "login.live.";

/**
* Encoded delimiter for redirect.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// 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.GsonBuilder
import com.google.gson.JsonParseException
import com.google.gson.JsonSyntaxException
import com.google.gson.stream.MalformedJsonException
import com.microsoft.identity.common.adal.internal.AuthenticationConstants
import com.microsoft.identity.common.internal.numberMatch.NumberMatchHelper
import com.microsoft.identity.common.logging.Logger
import java.net.MalformedURLException
import java.net.URL

/**
* 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
}

/**
* Helper method to determine if url is a valid Url for the JS Interface
* @param url url being loaded
* @return true if url is a valid, safe url, false otherwise
*/
fun isValidUrlForInterface(urlString: String?): Boolean {
// If url is null, return false
if (urlString.isNullOrEmpty()) {
return false
}

val url : URL
try {
url = URL(urlString)
} catch (e: MalformedURLException) {
// If url is not a valid URL, return false
Logger.warn(TAG, "Malformed URL passed.")
return false

}

val host = url.host

// Otherwise, make sure url is a valid url
// We only want to allow URLs that have the AAD or MSA url hosts
return host.startsWith(AuthenticationConstants.Broker.AAD_URL_HOST_PREFIX) ||
host.startsWith(AuthenticationConstants.Broker.MSA_URL_HOST_PREFIX)
}
}

/**
* 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 payloadObject = parseJsonToAuthUxJsonPayloadObject(jsonPayload)

Logger.info(methodTag, "Correlation ID during JavaScript Call: [${payloadObject.correlationId}]")


// TODO: Leaving these here, as these will be relevant for next WebCP feature
// val actionName = payloadObject.actionName
// val actionComponent = payloadObject.actionComponent

val parameters = payloadObject.params
if (parameters == null) {
Logger.warn(methodTag, "Payload from AuthUX contained no \"params\" field.")
return
}

val function = parameters.function

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

val data = parameters.data
if (data == null) {
Logger.warn(methodTag, "Payload from AuthUX contained no \"data\" field.")
return
}

when (function) {
FunctionNames.NUMBER_MATCH.name ->
NumberMatchHelper.storeNumberMatch(
data.sessionId,
data.numberMatch)
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.error(methodTag, "Payload with missing mandatory fields sent through JavaScriptInterface", e)
}
is MalformedJsonException, is JsonSyntaxException, is JsonParseException -> {
Logger.error(methodTag, "Error Parsing JSON payload sent through JavaScriptInterface", e)
}
else -> {
Logger.error(methodTag, "Unknown error occurred while processing the payload.", e)
}
}
}
}

private fun parseJsonToAuthUxJsonPayloadObject(jsonString: String): AuthUxJsonPayload{
val gson = GsonBuilder()
.registerTypeAdapter(AuthUxJsonPayload::class.java, AuthUxJsonPayloadKTDeserializer())
.create()
return gson.fromJson(jsonString, AuthUxJsonPayload::class.java)
}

/**
* Enum class to hold function names
*/
enum class FunctionNames {
NUMBER_MATCH
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// 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.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import com.google.gson.annotations.SerializedName
import java.lang.reflect.Type

/**
* Data class representing the JSON payload object received from AuthUX.
*
* @property correlationId The correlation ID for the request.
* @property actionName The name of the action being performed.
* @property actionComponent The component responsible for the action.
* @property params The parameters for the action, including function and data.
*/
data class AuthUxJsonPayload(
val correlationId: String,
val actionName: String,
val actionComponent: String,
val params: AuthUxParams?
)

/**
* Data class representing the parameters for the action, including function and data.
*
* @property function The function to be executed.
* @property data The data associated with the function.
*/
data class AuthUxParams(
@SerializedName(SerializedNames.FUNCTION)
val function: String?,

@SerializedName(SerializedNames.DATA)
val data: AuthUxData?
)

/**
* Data class representing the data associated with the JS API call.
*
* @property sessionId The session ID for the request.
* @property numberMatch The number match value.
*/
data class AuthUxData(
@SerializedName(SerializedNames.SESSION_ID)
val sessionId: String?,

@SerializedName(SerializedNames.NUMBER_MATCH)
val numberMatch: String?
)

class AuthUxJsonPayloadKTDeserializer : JsonDeserializer<AuthUxJsonPayload> {
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): AuthUxJsonPayload {
val jsonObject = json.asJsonObject

// Validate required fields
val correlationId = jsonObject.get(SerializedNames.CORRELATION_ID)?.asString
?: throw JsonParseException("correlationID is required and cannot be null")
val actionName = jsonObject.get(SerializedNames.ACTION_NAME)?.asString
?: throw JsonParseException("action_name is required and cannot be null")
val actionComponent = jsonObject.get(SerializedNames.ACTION_COMPONENT)?.asString
?: throw JsonParseException("action_component is required and cannot be null")

// Deserialize params if present
val params = jsonObject.get("params")?.let {
context.deserialize<AuthUxParams>(it, AuthUxParams::class.java)
}

return AuthUxJsonPayload(
correlationId = correlationId,
actionName = actionName,
actionComponent = actionComponent,
params = params
)
}
}

object SerializedNames {
const val CORRELATION_ID = "correlationID"
const val ACTION_NAME = "action_name"
const val ACTION_COMPONENT = "action_component"
const val PARAMS = "params"
const val FUNCTION = "function"
const val DATA = "data"
const val SESSION_ID = "sessionID"
const val NUMBER_MATCH = "numberMatch"
}
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()
}
}
}
Loading