diff --git a/changelog.txt b/changelog.txt index dd8c9df5fa..b773f3fe14 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,6 @@ vNext ---------- +- [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) - [MINOR] Refactor getAccountByLocalAccountId (#2781) diff --git a/common/build.gradle b/common/build.gradle index 81497209c3..e43a5e3f5d 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -151,6 +151,7 @@ android { } dependencies { + implementation "androidx.webkit:webkit:$rootProject.ext.webkitVersion" testImplementation project(path: ':testutils') localApi(project(":common4j")) { @@ -409,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() { diff --git a/common/src/main/assets/js-bridge.js b/common/src/main/assets/js-bridge.js new file mode 100644 index 0000000000..d0c5338a05 --- /dev/null +++ b/common/src/main/assets/js-bridge.js @@ -0,0 +1,205 @@ +// filepath: c:\repos\android-complete\common\common\src\main\java\com\microsoft\identity\common\internal\providers\oauth2\js-bridge.js +// +// WebAuthn JavaScript Bridge for Android Credential Manager +// +// This JavaScript code is injected into WebViews to intercept WebAuthn API calls +// (navigator.credentials.create/get) and bridge them to the Android Credential Manager. +// It handles the communication protocol between the web page and the native Android code. +// +// ⚠️ IMPORTANT: MINIFICATION REQUIRED ⚠️ +// Any modifications to this file MUST be minified and the minified output MUST be +// updated in PasskeyWebListener.kt in the constant WEB_AUTHN_INTERFACE_JS_MINIFIED. +// +// Steps to update: +// 1. Make changes to this file (js-bridge.js) +// 2. Minify the code using a JavaScript minifier (e.g., https://www.toptal.com/developers/javascript-minifier, https://minify-js.com/) +// 3. Update the WEB_AUTHN_INTERFACE_JS_MINIFIED constant in PasskeyWebListener.kt +// 4. Test thoroughly to ensure the minified version works correctly +// +// Failure to update the minified version will result in the changes NOT being +// reflected in the actual WebView injection. +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +var __webauthn_interface__; +var __webauthn_hooks__; +(function (__webauthn_hooks__) { + + //Adding event listener to the interface for replies by default + __webauthn_interface__.addEventListener('message', onReply); + // pendingResolveGet/Create is the thunk to resolve an outstanding get request. + var pendingResolveGet = null; + var pendingResolveCreate = null; + // pendingRejectGet/Create is the thunk to fail an outstanding request. + var pendingRejectGet = null; + var pendingRejectCreate = null; + // create overrides 'navigator.credentials.create' which proxies webauthn requests + // to the create embedder + function create(request) { + if (!("publicKey" in request)) { + return __webauthn_hooks__.originalCreateFunction(request); + } + var ret = new Promise(function (resolve, reject) { + pendingResolveCreate = resolve; + pendingRejectCreate = reject; + }); + var temppk = request.publicKey; + if (temppk.hasOwnProperty('challenge')) { + var str = CM_base64url_encode(temppk.challenge); + temppk.challenge = str; + } + if (temppk.hasOwnProperty('user') && temppk.user.hasOwnProperty('id')) { + var encodedString = CM_base64url_encode(temppk.user.id); + temppk.user.id = encodedString; + } + // Encode all excludeCredentials ids if provided + if (temppk.hasOwnProperty('excludeCredentials') && Array.isArray(temppk.excludeCredentials) && temppk.excludeCredentials.length > 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); + if(reply.type === "get") { + onReplyGet(reply); + } else if (reply.type === "create") { + onReplyCreate(reply); + } else { + console.log("Incorrect response format for reply: " + reply.type); + } + } + + // 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.status != 'success') { + var reject = pendingRejectGet; + pendingResolveGet = null; + pendingRejectGet = null; + reject(new DOMException(reply.data.domExceptionMessage, reply.data.domExceptionName)); + return; + } + var cred = credentialManagerDecode(reply.data); + 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.status != 'success') { + var reject = pendingRejectCreate; + pendingResolveCreate = null; + pendingRejectCreate = null; + reject(new DOMException(reply.data.domExceptionMessage, reply.data.domExceptionName)); + return; + } + var cred = credentialManagerDecode(reply.data); + 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(true); + }; 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..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 @@ -112,10 +112,13 @@ 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) { - throw ClientException(ClientException.PASSKEY_PROTOCOL_REQUEST_PARSING_ERROR, "Provided protocol version is not currently supported.") + if (FidoConstants.supportedPasskeyProtocolVersions.contains(version)) { + return version } - return version + throw ClientException( + ClientException.PASSKEY_PROTOCOL_REQUEST_PARSING_ERROR, + "Passkey protocol version '$version' is not supported. Supported versions: ${FidoConstants.supportedPasskeyProtocolVersions.joinToString()}" + ) } /** 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..5f5f5775f2 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CredentialManagerHandler.kt @@ -0,0 +1,86 @@ +// 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 androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +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 { + 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 { + 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 { + 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}" + ) + 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 methodTag = "$TAG:getPasskey" + val getRequest = GetCredentialRequest(listOf(GetPublicKeyCredentialOption(request))) + return mCredMan.getCredential(activity, getRequest).also { + Logger.info(methodTag, "Passkey retrieved successfully.") + } + } +} 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..74474d8bb5 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt @@ -0,0 +1,228 @@ +// 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.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.JSONObject +import kotlin.jvm.Throws + + +/** + * Communication channel for sending WebAuthn responses back to JavaScript via [JavaScriptReplyProxy]. + * + * Formats messages as JSON containing status, data, and request type for WebAuthn credential operations. + * + * @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, + private val requestType: String = "unknown" +) { + 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 + abstract val status: String + abstract val data: JSONObject + + /** + * 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" + 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) { + 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" + 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 + ) + } + + /** + * 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 (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 new file mode 100644 index 0000000000..b5db958a2c --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt @@ -0,0 +1,383 @@ +// 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.content.Context +import android.net.Uri +import android.os.Build +import android.webkit.WebView +import androidx.annotation.UiThread +import androidx.credentials.PublicKeyCredential +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.java.exception.ClientException +import com.microsoft.identity.common.logging.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.json.JSONObject +import java.util.concurrent.atomic.AtomicBoolean + +/** + * 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 coroutineScope: CoroutineScope, + private val credentialManagerHandler: CredentialManagerHandler, +) : WebViewCompat.WebMessageListener { + + /** 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. + * + * @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, + message: WebMessageCompat, + sourceOrigin: Uri, + isMainFrame: Boolean, + replyProxy: JavaScriptReplyProxy, + ) { + 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( + webAuthNMessage: WebAuthNMessage, + sourceOrigin: Uri, + isMainFrame: Boolean, + 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) + + // Only allow one request at a time. + if (havePendingRequest.get()) { + passkeyReplyChannel.postError("Request already in progress") + return + } + havePendingRequest.set(true) + + // Only allow requests from the main frame. + if (!isMainFrame) { + passkeyReplyChannel.postError("Requests from iframes are not supported") + havePendingRequest.set(false) + return + } + + when (webAuthNMessage.type) { + CREATE_UNIQUE_KEY -> + this.coroutineScope.launch { + handleCreateFlow( + credentialManagerHandler, + webAuthNMessage.request, + passkeyReplyChannel + ) + havePendingRequest.set(false) + } + + 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 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: PasskeyReplyChannel + ) { + runCatching { credentialManagerHandler.getPasskey(message) } + .onSuccess { credentialResponse -> + 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) + } + } + + /** + * 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: PasskeyReplyChannel + ) { + runCatching { credentialManagerHandler.createPasskey(message) } + .onSuccess { createCredentialResponse -> + reply.postSuccess(createCredentialResponse.registrationResponseJson) + } + .onFailure { throwable -> + reply.postError(throwable) + } + } + + /** + * 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? { + val passkeyReplyChannel = PasskeyReplyChannel(javaScriptReplyProxy) + 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) { + throw ClientException(ClientException.MISSING_PARAMETER, "Missing required key: type") + } else if (request == null) { + throw ClientException(ClientException.MISSING_PARAMETER, "Missing required key: request") + } else { + 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) + + companion object { + 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" + + /** Name of the JavaScript message port interface. */ + private const val INTERFACE_NAME = "__webauthn_interface__" + + /** + * Minified JavaScript code that intercepts WebAuthN API calls. + * + * ⚠️ 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;u { + 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. + * + * Requires Android 9+ and WebView WEB_MESSAGE_LISTENER support. + * + * @param webView WebView to attach to. + * @param activity Activity context for credential operations. + * @param webClient WebViewClient to inject JavaScript into. + * @return True if successfully hooked, false otherwise. + */ + @JvmStatic + fun hook( + webView: WebView, + activity: Activity, + webClient: AzureActiveDirectoryWebViewClient + ): Boolean { + val methodTag = "$TAG:hook" + + // Passkey features are supported only on Android 9 (API 28) and higher. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + Logger.warn( + methodTag, + "Passkey functionality requires Android 9 (Pie) or higher. " + + "Current version: ${Build.VERSION.SDK_INT}" + ) + return false + } + + return if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { + Logger.verbose(methodTag, "WEB_MESSAGE_LISTENER is supported on this WebView.") + + // Attach the WebMessageListener that handles WebAuthN/Passkey communication. + WebViewCompat.addWebMessageListener( + webView, + INTERFACE_NAME, + getAllowedOriginRules(), + PasskeyWebListener( + coroutineScope = CoroutineScope(Dispatchers.Default), + credentialManagerHandler = CredentialManagerHandler(activity) + ) + ) + + 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, + 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 + } + } + + } +} 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 49b44cf239..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 @@ -37,6 +37,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; @@ -74,6 +75,7 @@ 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; @@ -83,6 +85,7 @@ import static com.microsoft.identity.common.java.AuthenticationConstants.OAuth2.UTID; + import io.opentelemetry.api.trace.SpanContext; /** @@ -339,7 +342,7 @@ 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()); @@ -356,6 +359,7 @@ public Bitmap getDefaultVideoPoster() { return Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888); } }); + setupPasskeyWebListener(mWebView, webViewClient); } /** @@ -408,28 +412,20 @@ public void onDestroy() { private HashMap getRequestHeaders(final Bundle state) { try { // 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); - } - + @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) { 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()); } - return requestHeaders; + injectPasskeyProtocolHeader(headers); + return headers; } catch (Exception e) { - return null; + return new HashMap<>(); } } @@ -476,9 +472,69 @@ 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."); + } + } + + /** + * 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); + + if (StringUtil.isNullOrEmpty(webAuthNQueryParameter)) { + return; + } + + 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: " + + 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)); + } else { + Logger.verbose(methodTag, "Injecting Passkey protocol header for auth only."); + requestHeaders.put(FidoConstants.PASSKEY_PROTOCOL_HEADER_NAME, FidoConstants.PASSKEY_PROTOCOL_HEADER_AUTH_ONLY); + } + } + + } } 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 56282a1f90..dcdef663b5 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; @@ -87,11 +88,13 @@ 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 static com.microsoft.identity.common.adal.internal.AuthenticationConstants.Broker.AMAZON_APP_REDIRECT_PREFIX; @@ -142,6 +145,8 @@ public class AzureActiveDirectoryWebViewClient extends OAuth2WebViewClient { private final String mUtid; + private final List mOnPageStartedScripts = new ArrayList<>(); + public AzureActiveDirectoryWebViewClient(@NonNull final Activity activity, @NonNull final IAuthorizationCompletionCallback completionCallback, @NonNull final OnPageLoadedCallback pageLoadedCallback, @@ -255,7 +260,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(); @@ -1140,6 +1145,18 @@ 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); + // 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); + } + } + } + /** * Cleanup to be done when host activity is being destroyed. */ @@ -1218,4 +1235,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..642b3f26d1 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/JsScriptRecord.kt @@ -0,0 +1,88 @@ +// 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 androidx.core.net.toUri + +/** + * 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? +) { + + 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]. + * + * A script is considered allowed if: + * - [allowedUrls] is `null`, meaning no restrictions. + * - 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. + */ + fun isAllowedForUrl(url: String): Boolean { + // No restrictions — allowed for any URL + if (allowedUrls == null) return true + + val uri = url.toUri() + + // Check against each allowed URL + 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 + if (SOVEREIGN_CLOUD_URL_WITH_EXTRA_VALIDATION.contains(allowedUrl)) { + uri.path?.contains("fido", ignoreCase = true) == true + } else { + true + } + } else { + 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..36af7b4707 --- /dev/null +++ b/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt @@ -0,0 +1,291 @@ +// 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.credentials.exceptions.CreateCredentialCancellationException +import androidx.credentials.exceptions.CreateCredentialInterruptedException +import androidx.credentials.exceptions.CreateCredentialProviderConfigurationException +import androidx.credentials.exceptions.CreateCredentialUnknownException +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.* +import org.junit.Before +import org.junit.Test + +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 message format`() { + // Given + val testJson = """{"key": "value"}""" + val messageSlot = slot() + + // When + passkeyReplyChannel.postSuccess(testJson) + + // Then + verify { mockReplyProxy.postMessage(capture(messageSlot)) } + + val messageObject = JSONObject(messageSlot.captured) + assertEquals(PasskeyReplyChannel.SUCCESS_STATUS, messageObject.getString(PasskeyReplyChannel.STATUS_KEY)) + assertEquals(testRequestType, messageObject.getString(PasskeyReplyChannel.TYPE_KEY)) + + val dataObject = messageObject.getJSONObject(PasskeyReplyChannel.DATA_KEY) + 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 messageObject = JSONObject(messageSlot.captured) + val dataObject = messageObject.getJSONObject(PasskeyReplyChannel.DATA_KEY) + 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 + val exception = CreateCredentialCancellationException("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`() { + // Given + val exception = CreateCredentialInterruptedException("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 configuration exception returns NotSupportedError`() { + // Given + val exception = CreateCredentialProviderConfigurationException("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 unknown exception returns UnknownError`() { + // Given + val exception = CreateCredentialUnknownException("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 + fun `postError with NoCredentialException returns NotAllowedError`() { + // Given + val exception = NoCredentialException("No credentials") + 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 generic exception returns NotAllowedError`() { + // Given + val exception = RuntimeException("Generic 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_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 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 + val channelWithDefaultType = PasskeyReplyChannel(mockReplyProxy) + val messageSlot = slot() + + // When + channelWithDefaultType.postSuccess("""{"test": "data"}""") + + // Then + verify { mockReplyProxy.postMessage(capture(messageSlot)) } + + 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}, "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) + } + +} 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..75bdb5171b --- /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("Message data is null or blank")) + } + + @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/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")) + } +} + 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..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 @@ -68,9 +68,26 @@ 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" + + /** + * 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. @@ -101,9 +118,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. 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..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,10 +55,9 @@ 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_FEATURE("EnablePasskeyFeature", true), + ENABLE_PASSKEY_REGISTRATION("EnablePasskeyRegistration", false), /** * Flight to control the timeout duration for UrlConnection connect timeout. 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"