diff --git a/changelog.txt b/changelog.txt index b773f3fe14..a957384bb7 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,6 @@ vNext ---------- +- [MINOR] Add OpenTelemetry support for passkey operations (#2795) - [MINOR] Add passkey registration support for WebView (#2769) - [MINOR] Add callback for OneAuth for measuring Broker Discovery Client Perf (#2796) - [MINOR] Add new span name for DELEGATION_CERT_INSTALL's telemetry (#2790) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt index 74474d8bb5..43ca0d9a8c 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt @@ -33,9 +33,14 @@ import androidx.credentials.exceptions.GetCredentialProviderConfigurationExcepti import androidx.credentials.exceptions.GetCredentialUnknownException import androidx.credentials.exceptions.NoCredentialException import androidx.webkit.JavaScriptReplyProxy +import com.microsoft.identity.common.java.opentelemetry.AttributeName +import com.microsoft.identity.common.java.opentelemetry.OTelUtility +import com.microsoft.identity.common.java.opentelemetry.SpanExtension +import com.microsoft.identity.common.java.opentelemetry.SpanName import com.microsoft.identity.common.logging.Logger +import io.opentelemetry.api.trace.SpanContext +import io.opentelemetry.api.trace.StatusCode import org.json.JSONObject -import kotlin.jvm.Throws /** @@ -48,7 +53,8 @@ import kotlin.jvm.Throws */ class PasskeyReplyChannel( private val replyProxy: JavaScriptReplyProxy, - private val requestType: String = "unknown" + private val requestType: String = "unknown", + private val spanContext: SpanContext? = null ) { companion object { const val TAG = "PasskeyReplyChannel" @@ -78,8 +84,12 @@ class PasskeyReplyChannel( * Sealed class representing messages sent to JavaScript. */ sealed class ReplyMessage { + // Message type (e.g., "create", "get"). abstract val type: String + // Message status ("success" or "error"). abstract val status: String + // Message data as a JSON object. + // Either credential data for success or {domExceptionMessage, domExceptionName} for error. abstract val data: JSONObject /** @@ -102,8 +112,8 @@ class PasskeyReplyChannel( * @property type Request type that failed. */ class Error( - private val domExceptionMessage: String, - private val domExceptionName: String = DOM_EXCEPTION_NOT_ALLOWED_ERROR, + val domExceptionMessage: String, + val domExceptionName: String = DOM_EXCEPTION_NOT_ALLOWED_ERROR, override val type: String ) : ReplyMessage() { override val status = ERROR_STATUS @@ -131,23 +141,36 @@ class PasskeyReplyChannel( * * @param json JSON string containing the credential response. */ + @SuppressLint("RequiresFeature", "Only called when feature is available") fun postSuccess(json: String) { val methodTag = "$TAG:postSuccess" - send(ReplyMessage.Success(json, requestType)) - Logger.info(methodTag, "RequestType: $requestType, was successful.") - } - - /** - * Posts an error message with a custom error description. - * - * @param errorMessage Error description to send. - */ - fun postError(errorMessage: String) { - postErrorInternal( - ReplyMessage.Error(domExceptionMessage = errorMessage, type = requestType) + val span = OTelUtility.createSpanFromParent( + SpanName.PasskeyWebListener.name, + spanContext ) + + try { + SpanExtension.makeCurrentSpan(span).use { + val successMessage = ReplyMessage.Success(json, requestType).toString() + replyProxy.postMessage(successMessage) + Logger.info(methodTag, "RequestType: $requestType was successful.") + span.setAttribute(AttributeName.passkey_operation_type.name, requestType) + span.setStatus(StatusCode.OK) + } + } catch (throwable: Throwable) { + span.setStatus(StatusCode.ERROR) + span.setAttribute(AttributeName.passkey_operation_type.name, requestType) + span.recordException(throwable) + Logger.error(methodTag, "Reply message failed", throwable) + throw throwable + } finally { + span.end() + } } + + + /** * Posts an error message based on a thrown exception. * @@ -155,17 +178,33 @@ class PasskeyReplyChannel( * * @param throwable Exception to convert and send. */ + @SuppressLint("RequiresFeature", "Only called when feature is available") 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) + val span = OTelUtility.createSpanFromParent( + SpanName.PasskeyWebListener.name, + spanContext + ) + + try { + SpanExtension.makeCurrentSpan(span).use { + val errorMessage = throwableToErrorMessage(throwable) + replyProxy.postMessage(errorMessage.toString()) + span.setAttribute(AttributeName.passkey_operation_type.name, requestType) + span.setAttribute(AttributeName.passkey_dom_exception_name.name, errorMessage.domExceptionName) + span.setStatus(StatusCode.ERROR) + span.recordException(throwable) + Logger.error(methodTag, "RequestType: $requestType failed with error: $errorMessage", null) + } + } catch (unexpectedException: Throwable) { + span.setStatus(StatusCode.ERROR) + span.recordException(unexpectedException) + span.setAttribute(AttributeName.passkey_operation_type.name, requestType) + Logger.error(methodTag, "Reply message failed", unexpectedException) + throw unexpectedException + } finally { + span.end() // Always end the span + } } /** @@ -211,18 +250,4 @@ class PasskeyReplyChannel( type = requestType ) } - - /** - * Sends a message to JavaScript via the reply proxy. - */ - @SuppressLint("RequiresFeature", "Only called when feature is available") - private fun send(message: ReplyMessage) { - val methodTag = "$TAG:send" - try { - replyProxy.postMessage(message.toString()) - } catch (t: Throwable) { - Logger.error(methodTag, "Reply message failed", t) - throw t - } - } } diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt index b5db958a2c..7623467b13 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt @@ -110,14 +110,24 @@ class PasskeyWebListener( // Only allow one request at a time. if (havePendingRequest.get()) { - passkeyReplyChannel.postError("Request already in progress") + passkeyReplyChannel.postError( + ClientException( + ClientException.REQUEST_IN_PROGRESS, + "A WebAuthN request is already in progress." + ) + ) return } havePendingRequest.set(true) // Only allow requests from the main frame. if (!isMainFrame) { - passkeyReplyChannel.postError("Requests from iframes are not supported") + passkeyReplyChannel.postError( + ClientException( + ClientException.UNSUPPORTED_OPERATION, + "WebAuthN requests from iframes are not supported." + ) + ) havePendingRequest.set(false) return } @@ -143,7 +153,12 @@ class PasskeyWebListener( } else -> { - passkeyReplyChannel.postError("Unknown request type: ${webAuthNMessage.type}") + passkeyReplyChannel.postError( + ClientException( + ClientException.UNSUPPORTED_OPERATION, + "Unsupported WebAuthN request type: ${webAuthNMessage.type}" + ) + ) havePendingRequest.set(false) } } @@ -167,8 +182,12 @@ class PasskeyWebListener( if (publicKeyCredential != null) { reply.postSuccess(publicKeyCredential.authenticationResponseJson) } else { - reply.postError("Unexpected credential type: ${credentialResponse.credential.javaClass.name}") - } + reply.postError( + ClientException( + ClientException.UNSUPPORTED_OPERATION, + "Retrieved credential is not a PublicKeyCredential." + ) + ) } } .onFailure { throwable -> reply.postError(throwable) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java index dcdef663b5..18f70d4c37 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java @@ -141,8 +141,7 @@ public class AzureActiveDirectoryWebViewClient extends OAuth2WebViewClient { private boolean mAuthUxJavaScriptInterfaceAdded = false; // Determines whether to handle WebCP requests in the WebView in brokerless scenarios. private final boolean mIsWebViewWebCpEnabledInBrokerlessCase; - - + private final SpanContext mSpanContext; private final String mUtid; private final List mOnPageStartedScripts = new ArrayList<>(); @@ -159,6 +158,7 @@ public AzureActiveDirectoryWebViewClient(@NonNull final Activity activity, mCertBasedAuthFactory = new CertBasedAuthFactory(activity); mSwitchBrowserRequestHandler = switchBrowserRequestHandler; mUtid = utid; + mSpanContext = activity instanceof AuthorizationActivity ? ((AuthorizationActivity) getActivity()).getSpanContext() : null; mIsWebViewWebCpEnabledInBrokerlessCase = isWebViewWebCpEnabledInBrokerlessCase; } @@ -1022,9 +1022,7 @@ private void processNonceAndReAttachHeaders(@NonNull final WebView view, @NonNul AttributeName.is_sso_nonce_found_in_ests_request.name(), nonceQueryParam != null ); if (nonceQueryParam != null) { - final SpanContext spanContext = getActivity() instanceof AuthorizationActivity ? ((AuthorizationActivity) getActivity()).getSpanContext() : null; - final Span span = spanContext != null ? - OTelUtility.createSpanFromParent(SpanName.ProcessNonceFromEstsRedirect.name(), spanContext) : OTelUtility.createSpan(SpanName.ProcessNonceFromEstsRedirect.name()); + final Span span = OTelUtility.createSpanFromParent(SpanName.ProcessNonceFromEstsRedirect.name(), mSpanContext); try (final Scope scope = SpanExtension.makeCurrentSpan(span)) { final NonceRedirectHandler nonceRedirect = new NonceRedirectHandler(view, mRequestHeaders, span); nonceRedirect.processChallenge(new URL(url)); @@ -1063,9 +1061,7 @@ private void processWebCpAuthorize(@NonNull final WebView view, @NonNull final S private void processCrossCloudRedirect(@NonNull final WebView view, @NonNull final String url) { final String methodTag = TAG + ":processCrossCloudRedirect"; - final SpanContext spanContext = getActivity() instanceof AuthorizationActivity ? ((AuthorizationActivity) getActivity()).getSpanContext() : null; - final Span span = spanContext != null ? - OTelUtility.createSpanFromParent(SpanName.ProcessCrossCloudRedirect.name(), spanContext) : OTelUtility.createSpan(SpanName.ProcessCrossCloudRedirect.name()); + final Span span = OTelUtility.createSpanFromParent(SpanName.ProcessCrossCloudRedirect.name(), mSpanContext); final ReAttachPrtHeaderHandler reAttachPrtHeaderHandler = new ReAttachPrtHeaderHandler(view, mRequestHeaders, span); reAttachPrtHeader(url, reAttachPrtHeaderHandler, view, methodTag, span); } @@ -1212,9 +1208,7 @@ private String getBrokerAppPackageNameFromUrl(@NonNull final String url) { * @return Created {@link Span} */ private Span createSpanWithAttributesFromParent(@NonNull final String spanName) { - final SpanContext spanContext = getActivity() instanceof AuthorizationActivity ? ((AuthorizationActivity) getActivity()).getSpanContext() : null; - final Span span = spanContext != null ? - OTelUtility.createSpanFromParent(spanName, spanContext) : OTelUtility.createSpan(spanName); + final Span span = OTelUtility.createSpanFromParent(spanName, mSpanContext); if (mUtid != null) { span.setAttribute(AttributeName.tenant_id.name(), mUtid); } diff --git a/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt b/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt index 36af7b4707..6dbb2196cb 100644 --- a/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt +++ b/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt @@ -87,26 +87,6 @@ class PasskeyReplyChannelTest { assertEquals(0, dataObject.length()) } - @Test - fun `postError with string sends correct error format`() { - // Given - val errorMessage = "Test error message" - val messageSlot = slot() - - // When - passkeyReplyChannel.postError(errorMessage) - - // Then - verify { mockReplyProxy.postMessage(capture(messageSlot)) } - - val messageObject = JSONObject(messageSlot.captured) - val dataObject = messageObject.getJSONObject(PasskeyReplyChannel.DATA_KEY) - - assertEquals(errorMessage, dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_MESSAGE_KEY)) - assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_NOT_ALLOWED_ERROR, - dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY)) - } - @Test fun `postError with cancellation exception returns NotAllowedError`() { // Given diff --git a/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListenerTest.kt b/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListenerTest.kt index 75bdb5171b..a373f4fe50 100644 --- a/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListenerTest.kt +++ b/common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListenerTest.kt @@ -304,7 +304,7 @@ class PasskeyWebListenerTest { val responseObject = JSONObject(messageSlot.captured) assertEquals(PasskeyReplyChannel.ERROR_STATUS, responseObject.getString(PasskeyReplyChannel.STATUS_KEY)) val dataObject = responseObject.getJSONObject(PasskeyReplyChannel.DATA_KEY) - assertTrue(dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_MESSAGE_KEY).contains("Unknown request type")) + assertTrue(dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_MESSAGE_KEY).contains("Unsupported WebAuthN request type: unknown_type")) } // ========== Frame Origin Tests ========== diff --git a/common4j/src/main/com/microsoft/identity/common/java/exception/ClientException.java b/common4j/src/main/com/microsoft/identity/common/java/exception/ClientException.java index 8397e5241f..22b0131efb 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/exception/ClientException.java +++ b/common4j/src/main/com/microsoft/identity/common/java/exception/ClientException.java @@ -133,6 +133,16 @@ public class ClientException extends BaseException { */ public static final String UNSUPPORTED_ENCODING = "unsupported_encoding"; + /** + * The operation is not supported. + */ + public static final String UNSUPPORTED_OPERATION = "unsupported_operation"; + + /** + * The request is already in progress. + */ + public static final String REQUEST_IN_PROGRESS = "request_in_progress"; + /** * The designated crypto alg is not supported. */ diff --git a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java index 69780d9fca..ec86d11b18 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java +++ b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java @@ -487,5 +487,15 @@ public enum AttributeName { /** * Records if current flow is in webcp flow. */ - is_in_web_cp_flow + is_in_web_cp_flow, + + /** + * Passkey operation type (e.g., registration, authentication). + */ + passkey_operation_type, + + /** + * Passkey DOM exception name (if any). + */ + passkey_dom_exception_name, } diff --git a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/SpanName.java b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/SpanName.java index b0a76e255d..43bc71c959 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/SpanName.java +++ b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/SpanName.java @@ -72,6 +72,7 @@ public enum SpanName { GetAllSsoTokens, ProcessWebCpEnrollmentRedirect, ProcessWebCpAuthorizeUrlRedirect, + PasskeyWebListener, PersistToStorageAsync, InstallCertOnWpj }