diff --git a/changelog.txt b/changelog.txt index 44f98d7cc6..9dd4778ed7 100644 --- a/changelog.txt +++ b/changelog.txt @@ -7,6 +7,7 @@ vNext - [PATCH] Update Moshi to 1.15.2 to resolve okio CVE-2023-3635 vulnerability (#3005) - [MINOR] Handle target="_blank" links in authorization WebView (#3010) - [MINOR] Handle openid-vc urls in webview (#3013) +- [MINOR] Enhance WebAuthn telemetry for passkey registration (#3018) Version 24.0.1 ---------- diff --git a/common/build.gradle b/common/build.gradle index 40936b70b2..e9f3f8b4e3 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -275,6 +275,7 @@ dependencies { }) implementation "io.opentelemetry:opentelemetry-api:$rootProject.ext.openTelemetryVersion" + implementation("io.opentelemetry:opentelemetry-extension-kotlin:$rootProject.ext.openTelemetryVersion") implementation "androidx.fragment:fragment:1.3.2" } diff --git a/common/src/main/java/com/microsoft/identity/common/internal/fido/LegacyFidoActivityResultContract.kt b/common/src/main/java/com/microsoft/identity/common/internal/fido/LegacyFidoActivityResultContract.kt index f39814817e..72d4537d1a 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/fido/LegacyFidoActivityResultContract.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/fido/LegacyFidoActivityResultContract.kt @@ -33,7 +33,7 @@ import com.google.android.gms.fido.Fido import com.google.android.gms.fido.fido2.api.common.AuthenticatorAssertionResponse import com.google.android.gms.fido.fido2.api.common.AuthenticatorErrorResponse import com.google.android.gms.fido.fido2.api.common.PublicKeyCredential -import com.microsoft.identity.common.internal.fido.WebAuthnJsonUtil.Companion.createAssertionString +import com.microsoft.identity.common.internal.fido.WebAuthnJsonUtil.createAssertionString import com.microsoft.identity.common.java.util.StringUtil import com.microsoft.identity.common.logging.Logger diff --git a/common/src/main/java/com/microsoft/identity/common/internal/fido/WebAuthnJsonUtil.kt b/common/src/main/java/com/microsoft/identity/common/internal/fido/WebAuthnJsonUtil.kt index ed807cbeef..11baf0fc33 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/fido/WebAuthnJsonUtil.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/fido/WebAuthnJsonUtil.kt @@ -24,119 +24,316 @@ package com.microsoft.identity.common.internal.fido import android.util.Base64 import com.microsoft.identity.common.internal.util.CommonMoshiJsonAdapter -import com.microsoft.identity.common.java.constants.FidoConstants +import com.microsoft.identity.common.java.constants.FidoConstants.Companion.WEBAUTHN_AUTHENTICATION_ASSERTION_RESPONSE_JSON_KEY +import com.microsoft.identity.common.java.constants.FidoConstants.Companion.WEBAUTHN_AUTHDATA_AAGUID_LENGTH +import com.microsoft.identity.common.java.constants.FidoConstants.Companion.WEBAUTHN_AUTHDATA_AAGUID_OFFSET +import com.microsoft.identity.common.java.constants.FidoConstants.Companion.WEBAUTHN_AUTHDATA_ATTESTED_CREDENTIAL_DATA_FLAG +import com.microsoft.identity.common.java.constants.FidoConstants.Companion.WEBAUTHN_AUTHDATA_FLAGS_OFFSET +import com.microsoft.identity.common.java.constants.FidoConstants.Companion.WEBAUTHN_REGISTRATION_ATTESTATION_OBJECT_JSON_KEY +import com.microsoft.identity.common.java.constants.FidoConstants.Companion.WEBAUTHN_REGISTRATION_ORIGIN_JSON_KEY import com.microsoft.identity.common.java.constants.FidoConstants.Companion.WEBAUTHN_RESPONSE_AUTHENTICATOR_DATA_JSON_KEY import com.microsoft.identity.common.java.constants.FidoConstants.Companion.WEBAUTHN_RESPONSE_CLIENT_DATA_JSON_KEY import com.microsoft.identity.common.java.constants.FidoConstants.Companion.WEBAUTHN_RESPONSE_ID_JSON_KEY import com.microsoft.identity.common.java.constants.FidoConstants.Companion.WEBAUTHN_RESPONSE_SIGNATURE_JSON_KEY import com.microsoft.identity.common.java.constants.FidoConstants.Companion.WEBAUTHN_RESPONSE_USER_HANDLE_JSON_KEY import com.microsoft.identity.common.logging.Logger +import okio.ByteString.Companion.decodeBase64 import org.json.JSONException import org.json.JSONObject +import java.nio.ByteBuffer +import java.util.UUID +import kotlin.text.toByteArray /** * A utility class to help convert to and from strings in WebAuthn json format. */ -class WebAuthnJsonUtil { - companion object { - - private val TAG = WebAuthnJsonUtil::class.simpleName.toString() - - /** - * Takes applicable parameters and creates a string representation of - * PublicKeyCredentialRequestOptionsJSON (https://w3c.github.io/webauthn/#dictdef-publickeycredentialrequestoptionsjson) - * @param challenge challenge string - * @param relyingPartyIdentifier rpId string - * @param allowedCredentials allowedCredentials string - * @param userVerificationPolicy yserVerificationPolicy string - * @return a string representation of PublicKeyCredentialRequestOptionsJSON. - */ - fun createJsonAuthRequest(challenge: String, - relyingPartyIdentifier: String, - allowedCredentials: List?, - userVerificationPolicy: String): String { - //Create classes - val publicKeyCredentialDescriptorList = ArrayList() - allowedCredentials?.let { - for (id in allowedCredentials) { - publicKeyCredentialDescriptorList.add( - PublicKeyCredentialDescriptor("public-key", id) - ) - } +object WebAuthnJsonUtil { + + private val TAG = WebAuthnJsonUtil::class.simpleName.toString() + + /** + * Takes applicable parameters and creates a string representation of + * PublicKeyCredentialRequestOptionsJSON (https://w3c.github.io/webauthn/#dictdef-publickeycredentialrequestoptionsjson) + * @param challenge challenge string + * @param relyingPartyIdentifier rpId string + * @param allowedCredentials allowedCredentials string + * @param userVerificationPolicy userVerificationPolicy string + * @return a string representation of PublicKeyCredentialRequestOptionsJSON. + */ + fun createJsonAuthRequest( + challenge: String, + relyingPartyIdentifier: String, + allowedCredentials: List?, + userVerificationPolicy: String + ): String { + //Create classes + val publicKeyCredentialDescriptorList = ArrayList() + allowedCredentials?.let { + for (id in allowedCredentials) { + publicKeyCredentialDescriptorList.add( + PublicKeyCredentialDescriptor("public-key", id) + ) } - val options = PublicKeyCredentialRequestOptions( - challenge.base64UrlEncoded(), - relyingPartyIdentifier, - publicKeyCredentialDescriptorList, - userVerificationPolicy - ) - return CommonMoshiJsonAdapter().toJson(options) } + val options = PublicKeyCredentialRequestOptions( + challenge.base64UrlEncoded(), + relyingPartyIdentifier, + publicKeyCredentialDescriptorList, + userVerificationPolicy + ) + return CommonMoshiJsonAdapter().toJson(options) + } - /** - * Extracts the AuthenticatorAssertionResponse from the overall AuthenticationResponse string received from the authenticator. - * @param fullResponseJson AuthenticationResponse Json string. - * @throws JSONException if a value is not present that should be. - */ - fun extractAuthenticatorAssertionResponseJson(fullResponseJson : String): String { - val methodTag = "$TAG:extractAuthenticatorAssertionResponseJson" - val fullResponseJsonObject = JSONObject(fullResponseJson); - val authResponseJsonObject = fullResponseJsonObject - .getJSONObject(FidoConstants.WEBAUTHN_AUTHENTICATION_ASSERTION_RESPONSE_JSON_KEY) - // ESTS expects a custom object with clientDataJSON, authenticatorData, signature, userHandle, and id. - val assertionResult = JSONObject(); - assertionResult.put(WEBAUTHN_RESPONSE_ID_JSON_KEY, fullResponseJsonObject.get( - WEBAUTHN_RESPONSE_ID_JSON_KEY)) - assertionResult.put(WEBAUTHN_RESPONSE_AUTHENTICATOR_DATA_JSON_KEY, authResponseJsonObject.get( - WEBAUTHN_RESPONSE_AUTHENTICATOR_DATA_JSON_KEY)) - assertionResult.put(WEBAUTHN_RESPONSE_CLIENT_DATA_JSON_KEY, authResponseJsonObject.get( - WEBAUTHN_RESPONSE_CLIENT_DATA_JSON_KEY)) - assertionResult.put(WEBAUTHN_RESPONSE_SIGNATURE_JSON_KEY, authResponseJsonObject.get( - WEBAUTHN_RESPONSE_SIGNATURE_JSON_KEY)) - // UserHandle is optional if allowCredentials was provided in the request (username flow). - if (authResponseJsonObject.isNull(WEBAUTHN_RESPONSE_USER_HANDLE_JSON_KEY)) { - Logger.info(methodTag, "UserHandle not found in assertion response.") - } else { - Logger.info(methodTag, "UserHandle was included in assertion response.") - assertionResult.put( - WEBAUTHN_RESPONSE_USER_HANDLE_JSON_KEY, authResponseJsonObject.get( - WEBAUTHN_RESPONSE_USER_HANDLE_JSON_KEY - ) + /** + * Extracts the AuthenticatorAssertionResponse from the overall AuthenticationResponse string received from the authenticator. + * @param fullResponseJson AuthenticationResponse Json string. + * @throws JSONException if a value is not present that should be. + */ + fun extractAuthenticatorAssertionResponseJson(fullResponseJson: String): String { + val methodTag = "$TAG:extractAuthenticatorAssertionResponseJson" + val fullResponseJsonObject = JSONObject(fullResponseJson) + val authResponseJsonObject = fullResponseJsonObject + .getJSONObject(WEBAUTHN_AUTHENTICATION_ASSERTION_RESPONSE_JSON_KEY) + // ESTS expects a custom object with clientDataJSON, authenticatorData, signature, userHandle, and id. + val assertionResult = JSONObject() + assertionResult.put( + WEBAUTHN_RESPONSE_ID_JSON_KEY, fullResponseJsonObject.get( + WEBAUTHN_RESPONSE_ID_JSON_KEY + ) + ) + assertionResult.put( + WEBAUTHN_RESPONSE_AUTHENTICATOR_DATA_JSON_KEY, authResponseJsonObject.get( + WEBAUTHN_RESPONSE_AUTHENTICATOR_DATA_JSON_KEY + ) + ) + assertionResult.put( + WEBAUTHN_RESPONSE_CLIENT_DATA_JSON_KEY, authResponseJsonObject.get( + WEBAUTHN_RESPONSE_CLIENT_DATA_JSON_KEY + ) + ) + assertionResult.put( + WEBAUTHN_RESPONSE_SIGNATURE_JSON_KEY, authResponseJsonObject.get( + WEBAUTHN_RESPONSE_SIGNATURE_JSON_KEY + ) + ) + // UserHandle is optional if allowCredentials was provided in the request (username flow). + if (authResponseJsonObject.isNull(WEBAUTHN_RESPONSE_USER_HANDLE_JSON_KEY)) { + Logger.info(methodTag, "UserHandle not found in assertion response.") + } else { + Logger.info(methodTag, "UserHandle was included in assertion response.") + assertionResult.put( + WEBAUTHN_RESPONSE_USER_HANDLE_JSON_KEY, authResponseJsonObject.get( + WEBAUTHN_RESPONSE_USER_HANDLE_JSON_KEY ) - } - return assertionResult.toString() + ) + } + return assertionResult.toString() + } + + /** + * Given WebAuthn response values, create a string representation of the JSON assertion response that ESTS is expecting. + * @clientDataJson clientDataJson string + * @authenticatorData authenticatorData string + * @signature signature string + * @userHandle userHandle string + * @id id string + */ + @JvmStatic + fun createAssertionString( + clientDataJson: String, + authenticatorData: String, + signature: String, + userHandle: String, + id: String + ): String { + val assertionResult = JSONObject() + assertionResult.put(WEBAUTHN_RESPONSE_ID_JSON_KEY, id) + assertionResult.put(WEBAUTHN_RESPONSE_AUTHENTICATOR_DATA_JSON_KEY, authenticatorData) + assertionResult.put(WEBAUTHN_RESPONSE_CLIENT_DATA_JSON_KEY, clientDataJson) + assertionResult.put(WEBAUTHN_RESPONSE_SIGNATURE_JSON_KEY, signature) + assertionResult.put(WEBAUTHN_RESPONSE_USER_HANDLE_JSON_KEY, userHandle) + return assertionResult.toString() + } + + /** + * Returns a base64URL encoding of the string. + * @return String + */ + fun String.base64UrlEncoded(): String { + val data: ByteArray = this.toByteArray(Charsets.UTF_8) + return Base64.encodeToString( + data, + (Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) + ) + } + + /** + * Extracts the origin from a WebAuthn passkey registration response JSON. + * + * Intended for use with the `registrationResponseJson` returned by + * `CredentialManagerHandler.createPasskey()`. Navigates to `response.clientDataJSON`, + * base64url-decodes it, parses it as JSON, and returns the `"origin"` field. + * + * @param registrationResponseJson The `registrationResponseJson` string from + * `CreatePublicKeyCredentialResponse`. + * @return The origin string, or null if extraction fails. + */ + fun extractOriginFromRegistrationResponse(registrationResponseJson: String): String? { + return try { + val responseObj = JSONObject(registrationResponseJson) + .getJSONObject(WEBAUTHN_AUTHENTICATION_ASSERTION_RESPONSE_JSON_KEY) + val clientDataB64 = responseObj + .getString(WEBAUTHN_RESPONSE_CLIENT_DATA_JSON_KEY) + val decodedClientDataBytes = Base64.decode(clientDataB64, Base64.URL_SAFE) + val jsonString = String(decodedClientDataBytes, Charsets.UTF_8) + JSONObject(jsonString).getString(WEBAUTHN_REGISTRATION_ORIGIN_JSON_KEY) + } catch (e: Exception) { + Logger.warn(TAG, "Failed to extract origin from passkey registration response: ${e.message}") + null + } + } + + /** + * Extracts the AAGUID from a WebAuthn passkey registration response JSON. + * + * Intended for use with the `registrationResponseJson` returned by + * `CredentialManagerHandler.createPasskey()`. Navigates to `response.attestationObject`, + * base64url-decodes it, parses the CBOR-encoded authenticator data, and returns the AAGUID + * as a formatted UUID string. + * + * @param registrationResponseJson The `registrationResponseJson` string from + * `CreatePublicKeyCredentialResponse`. + * @return The AAGUID as a UUID string, or null if extraction fails. + */ + fun extractAaguidFromRegistrationResponse(registrationResponseJson: String): String? { + return try { + val responseObj = JSONObject(registrationResponseJson) + .getJSONObject(WEBAUTHN_AUTHENTICATION_ASSERTION_RESPONSE_JSON_KEY) + val attestationObject = responseObj + .getString(WEBAUTHN_REGISTRATION_ATTESTATION_OBJECT_JSON_KEY) + extractAaguidFromAttestationObject(attestationObject) + } catch (e: Exception) { + Logger.warn(TAG, "Failed to extract AAGUID from passkey registration response: ${e.message}") + null } + } + + /** + * Parses a base64url-encoded CBOR attestation object and extracts the AAGUID. + * + * The AAGUID is read from the attested credential data section of `authData`. + * This method first verifies the attested credential data flag at + * [WEBAUTHN_AUTHDATA_FLAGS_OFFSET], then reads the AAGUID from + * [WEBAUTHN_AUTHDATA_AAGUID_OFFSET] for [WEBAUTHN_AUTHDATA_AAGUID_LENGTH] bytes. + * + * @param attestationString String representation of the attestation object, as received in the WebAuthn registration response JSON. + * @return The AAGUID as a UUID string. + * @throws Exception if the attestation object is malformed or cannot be decoded. + */ + fun extractAaguidFromAttestationObject(attestationString: String): String { + // 1. Base64URL-decode the attestation object. + val attestationObject = attestationString.decodeBase64()?.toByteArray() + ?: throw Exception("Failed to base64url-decode the attestation object.") + + // 2. Locate the 'authData' CBOR key and read its byte-string payload. + val key = "authData".toByteArray(Charsets.UTF_8) + val keyIndex = indexOf(attestationObject, key) + if (keyIndex == -1) throw Exception("'authData' key not found in attestation object (size: ${attestationObject.size} bytes).") - /** - * Given WebAuthn response values, create a string representation of the JSON assertion response that ESTS is expecting. - * @clientDataJson clientDataJson string - * @authenticatorData authenticatorData string - * @signature signature string - * @userHandle userHandle string - * @id id string - */ - @JvmStatic - fun createAssertionString(clientDataJson: String, - authenticatorData: String, - signature: String, - userHandle: String, - id: String): String { - val assertionResult = JSONObject(); - assertionResult.put(WEBAUTHN_RESPONSE_ID_JSON_KEY, id) - assertionResult.put(WEBAUTHN_RESPONSE_AUTHENTICATOR_DATA_JSON_KEY, authenticatorData) - assertionResult.put(WEBAUTHN_RESPONSE_CLIENT_DATA_JSON_KEY, clientDataJson) - assertionResult.put(WEBAUTHN_RESPONSE_SIGNATURE_JSON_KEY, signature) - assertionResult.put(WEBAUTHN_RESPONSE_USER_HANDLE_JSON_KEY, userHandle) - return assertionResult.toString() + val valueOffset = keyIndex + key.size + if (valueOffset >= attestationObject.size) { + throw Exception("Attestation object truncated after 'authData' key (offset: $keyIndex, size: ${attestationObject.size} bytes).") } - /** - * Returns a base64URL encoding of the string. - * @return String - */ - fun String.base64UrlEncoded(): String { - val data: ByteArray = this.toByteArray(Charsets.UTF_8) - return Base64.encodeToString(data, (Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)) + val initialByte = attestationObject[valueOffset].toInt() and 0xFF + val majorType = (initialByte shr 5) and 0x07 + if (majorType != 2) { + throw Exception("Invalid CBOR major type for 'authData': expected byte string (major type 2) but found $majorType at offset $valueOffset.") } + + val (headerSize, authDataLength) = parseCborByteStringHeader(attestationObject, valueOffset, initialByte) + val authDataStart = valueOffset + headerSize + val authDataEnd = authDataStart + authDataLength + if (authDataEnd > attestationObject.size) { + throw Exception("Attestation object truncated: declared 'authData' length $authDataLength at offset $authDataStart exceeds buffer size ${attestationObject.size} bytes.") + } + + // 3. Verify the AT flag — confirms attested credential data (and therefore AAGUID) is present. + val flagsByteIndex = authDataStart + WEBAUTHN_AUTHDATA_FLAGS_OFFSET + if (flagsByteIndex >= authDataEnd) { + throw Exception("authData truncated: flags byte missing at offset $flagsByteIndex (authData length: $authDataLength bytes).") + } + if ((attestationObject[flagsByteIndex].toInt() and WEBAUTHN_AUTHDATA_ATTESTED_CREDENTIAL_DATA_FLAG) == 0) { + throw Exception("AT flag not set in authData flags, attested credential data is absent, AAGUID cannot be extracted.") + } + + // 4. Extract the fixed-length AAGUID. + val aaguidStart = authDataStart + WEBAUTHN_AUTHDATA_AAGUID_OFFSET + if (aaguidStart + WEBAUTHN_AUTHDATA_AAGUID_LENGTH > authDataEnd) { + throw Exception("authData truncated: expected $WEBAUTHN_AUTHDATA_AAGUID_LENGTH AAGUID bytes at offset $aaguidStart (authData length: $authDataLength bytes).") + } + return formatToUuid(attestationObject.copyOfRange(aaguidStart, aaguidStart + WEBAUTHN_AUTHDATA_AAGUID_LENGTH)) + } + + /** + * Reads the CBOR byte-string length starting at [offset] in [buf]. + * + * CBOR encodes the length in the low 5 bits of the initial byte: + * - 0–23 → length is the value itself (1-byte header) + * - 24 → next 1 byte holds the length (2-byte header) + * - 25 → next 2 bytes hold the length, big-endian (3-byte header) + * - 26 → next 4 bytes hold the length, big-endian (5-byte header) + * + * @param buf The raw bytes of the attestation object. + * @param offset Index of the initial CBOR byte (already verified to be major type 2). + * @param initialByte The byte at [offset], pre-read by the caller. + * @return Pair of (headerSize, byteStringLength). + * @throws Exception if the buffer is truncated or the length encoding is unsupported. + */ + private fun parseCborByteStringHeader(buf: ByteArray, offset: Int, initialByte: Int): Pair { + val additionalInfo = initialByte and 0x1F + return when (additionalInfo) { + in 0..23 -> 1 to additionalInfo + 24 -> { + val li = offset + 1 + if (li >= buf.size) throw Exception("Attestation object truncated while reading 'authData' length (need 1 byte at offset $li, size: ${buf.size} bytes).") + 2 to (buf[li].toInt() and 0xFF) + } + 25 -> { + val li = offset + 1 + if (li + 1 >= buf.size) throw Exception("Attestation object truncated while reading 'authData' length (need 2 bytes starting at offset $li, size: ${buf.size} bytes).") + val length = ((buf[li].toInt() and 0xFF) shl 8) or (buf[li + 1].toInt() and 0xFF) + 3 to length + } + 26 -> { + val li = offset + 1 + if (li + 3 >= buf.size) throw Exception("Attestation object truncated while reading 'authData' length (need 4 bytes starting at offset $li, size: ${buf.size} bytes).") + val length = ((buf[li].toInt() and 0xFF) shl 24) or + ((buf[li + 1].toInt() and 0xFF) shl 16) or + ((buf[li + 2].toInt() and 0xFF) shl 8) or + (buf[li + 3].toInt() and 0xFF) + 5 to length + } + else -> throw Exception("Unsupported CBOR length encoding for 'authData': initial byte 0x${initialByte.toString(16).uppercase()} at offset $offset.") + } + } + + /** + * Returns the starting index of the first occurrence of [target] within [outer], + * or -1 if not found. + */ + private fun indexOf(outer: ByteArray, target: ByteArray): Int { + for (i in 0 until outer.size - target.size + 1) { + if (outer.sliceArray(i until i + target.size).contentEquals(target)) return i + } + return -1 + } + + /** + * Converts a 16-byte AAGUID byte array into a formatted UUID string (e.g. "550e8400-e29b-41d4-a716-446655440000"). + */ + private fun formatToUuid(bytes: ByteArray): String { + val bb = ByteBuffer.wrap(bytes) + return UUID(bb.long, bb.long).toString() } } diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/AuthorizationActivity.java b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/AuthorizationActivity.java index 9decdb25e6..593df2b74a 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/AuthorizationActivity.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/AuthorizationActivity.java @@ -51,7 +51,7 @@ public class AuthorizationActivity extends DualScreenActivity { @Accessors(prefix = "m") private SpanContext mSpanContext; - @Getter + @Accessors(prefix = "m") private Context mOtelContext; @@ -98,4 +98,13 @@ public void onCreate(@Nullable Bundle savedInstanceState) { } setFragment(mFragment); } + + /** + * Returns the OpenTelemetry Context extracted from the Intent extras, + * or null if it was not provided or an error occurred during extraction. + * @return the OpenTelemetry Context, or null if not available. + */ + public final Context getOtelContext() { + return mOtelContext; + } } 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 ed4981c855..4baf7bcaa4 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 @@ -24,6 +24,10 @@ package com.microsoft.identity.common.internal.providers.oauth2 import android.annotation.SuppressLint import androidx.credentials.exceptions.CreateCredentialCancellationException +import com.microsoft.identity.common.internal.fido.WebAuthnJsonUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import androidx.credentials.exceptions.CreateCredentialInterruptedException import androidx.credentials.exceptions.CreateCredentialProviderConfigurationException import androidx.credentials.exceptions.CreateCredentialUnknownException @@ -34,12 +38,17 @@ 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.BaggageExtension 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.baggage.Baggage +import io.opentelemetry.api.trace.Span import io.opentelemetry.api.trace.SpanContext import io.opentelemetry.api.trace.StatusCode +import io.opentelemetry.context.Context +import io.opentelemetry.extension.kotlin.asContextElement import org.json.JSONObject @@ -50,11 +59,16 @@ import org.json.JSONObject * * @property replyProxy Proxy for sending messages to JavaScript. * @property requestType Type of WebAuthn request (e.g., "create", "get"). Defaults to "unknown". + * @property Context for OpenTelemetry span creation. Optional; if not provided, telemetry will be skipped with a warning. + * @property telemetryScope Coroutine scope used to record additional telemetry in a background + * thread after the reply has already been posted, so callers are not blocked by telemetry work. + * Defaults to a new [CoroutineScope] backed by [Dispatchers.IO]. */ class PasskeyReplyChannel( private val replyProxy: JavaScriptReplyProxy, private val requestType: String = "unknown", - private val spanContext: SpanContext? = null + private val otelContext: Context? = null, + private val telemetryScope: CoroutineScope = CoroutineScope(Dispatchers.IO) ) { companion object { const val TAG = "PasskeyReplyChannel" @@ -78,6 +92,12 @@ class PasskeyReplyChannel( const val DOM_EXCEPTION_NOT_SUPPORTED_ERROR = "NotSupportedError" const val DOM_EXCEPTION_UNKNOWN_ERROR = "UnknownError" + private val parentAttributeNames = arrayListOf( + AttributeName.correlation_id, + AttributeName.tenant_id, + AttributeName.account_type, + AttributeName.calling_package_name + ) } /** @@ -137,7 +157,28 @@ class PasskeyReplyChannel( } /** - * Posts a success message with credential data. + * Resolves the parent [SpanContext] from the provided [otelContext]. + * + * Returns `null` and emits a warning if no OTel context was supplied, so callers can still + * create a root span rather than failing outright. + * + * @param methodTag Logging tag of the calling method, included in the warning message. + * @return The [SpanContext] extracted from [otelContext], or `null`. + */ + private fun resolveParentSpanContext(methodTag: String): SpanContext? = + if (otelContext == null) { + Logger.warn(methodTag, "No OpenTelemetry context provided. Telemetry will not be recorded for this operation.") + null + } else { + SpanExtension.fromContext(otelContext).spanContext + } + + /** + * Posts a success message with credential data and returns immediately. + * + * The reply is sent to JavaScript right away. Additional telemetry attributes that require + * inspecting the response (e.g. passkey_origin) are recorded asynchronously + * on [telemetryScope] so that the caller is never blocked by telemetry work. * * @param json JSON string containing the credential response. */ @@ -146,30 +187,84 @@ class PasskeyReplyChannel( val methodTag = "$TAG:postSuccess" val span = OTelUtility.createSpanFromParent( SpanName.PasskeyWebListener.name, - spanContext + resolveParentSpanContext(methodTag) ) + // We use a flag or a structured try-catch to ensure span.end() + // is called exactly once. + var handedOffToBackground = false 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) + // 1. Immediate Work + val successMessage = ReplyMessage.Success(json, requestType).toString() + replyProxy.postMessage(successMessage) + + span.setStatus(StatusCode.OK) + span.setAttribute(AttributeName.passkey_operation_type.name, requestType) + val otelContextCurrentSpan = Context.current().with(span) + val baggage = BaggageExtension.fromContext(otelContext) + + // 2. Hand off post-success telemetry to background worker + val job = telemetryScope.launch(otelContextCurrentSpan.asContextElement()) { + recordPostSuccessTelemetry(span, json, baggage) } + job.invokeOnCompletion { + span.end() + } + handedOffToBackground = true + } catch (throwable: Throwable) { - span.setStatus(StatusCode.ERROR) - span.setAttribute(AttributeName.passkey_operation_type.name, requestType) + // 3. Error Path: If postMessage fails OR launch fails span.recordException(throwable) - Logger.error(methodTag, "Reply message failed", throwable) + span.setStatus(StatusCode.ERROR) + Logger.error(methodTag, "Immediate execution failed", throwable) throw throwable } finally { - span.end() + // 4. Lifecycle Guard: Only end here if we didn't successfully + // start the background task. + if (!handedOffToBackground) { + span.end() + } } } + /** + * Records additional post-success telemetry on a background thread. + * + * This method is invoked only after the success reply has already been posted to JavaScript. + * It must remain non-blocking for the caller path and should never prevent a successful + * response from being returned. + * + * @param span The span created in [postSuccess], owned by the background task lifecycle. + * @param json JSON payload returned from WebAuthn operation. + */ + private fun recordPostSuccessTelemetry(span: Span, json: String, baggage: Baggage? = null) { + try { + SpanExtension.makeCurrentSpan(span).use { + parentAttributeNames.forEach { attributeName -> + baggage?.getEntryValue(attributeName.name)?.let { value -> + span.setAttribute(attributeName.name, value) + } + } - + if (requestType == PasskeyWebListener.CREATE_UNIQUE_KEY) { + WebAuthnJsonUtil.extractAaguidFromRegistrationResponse(json)?.let { + span.setAttribute(AttributeName.passkey_aaguid.name, it) + } + WebAuthnJsonUtil.extractOriginFromRegistrationResponse(json)?.let { + span.setAttribute(AttributeName.passkey_origin.name, it) + } + } + } + } catch (exception: Exception) { + Logger.warn( + TAG, + "Failed to record post-success passkey telemetry for requestType: $requestType, ${exception.message}" + ) + } finally { + // The background worker owns span completion for the success path. + span.end() + } + } /** * Posts an error message based on a thrown exception. @@ -183,7 +278,7 @@ class PasskeyReplyChannel( val methodTag = "$TAG:postError" val span = OTelUtility.createSpanFromParent( SpanName.PasskeyWebListener.name, - spanContext + resolveParentSpanContext(methodTag) ) try { diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt index a12f1fd1af..d78a583432 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt @@ -34,6 +34,7 @@ import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import com.microsoft.identity.common.BuildConfig +import com.microsoft.identity.common.internal.providers.oauth2.PasskeyWebListener.Companion.hook import com.microsoft.identity.common.internal.ui.webview.AzureActiveDirectoryWebViewClient import com.microsoft.identity.common.java.exception.ClientException import com.microsoft.identity.common.logging.Logger @@ -49,12 +50,21 @@ import java.util.concurrent.atomic.AtomicBoolean * 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. + * ## Threading model + * - [onPostMessage] is always invoked on the **main thread** by the WebView framework. + * - Credential operations ([handleCreateFlow], [handleGetFlow]) are launched as coroutines on + * [kotlinx.coroutines.Dispatchers.Main] because [androidx.credentials.CredentialManager] + * must be called from the main thread in order to display its system UI. + * - The [coroutineScope] supplied at construction time **must** therefore be bound to + * [kotlinx.coroutines.Dispatchers.Main] (see [hook] for the canonical way to create an instance). + * + * @property coroutineScope Scope for launching credential operations (must use [kotlinx.coroutines.Dispatchers.Main]). * @property credentialManagerHandler Handles passkey creation and retrieval. */ class PasskeyWebListener( private val coroutineScope: CoroutineScope, private val credentialManagerHandler: CredentialManagerHandler, + private val otelContext: io.opentelemetry.context.Context? = null ) : WebViewCompat.WebMessageListener { /** Tracks if a WebAuthN request is currently pending. Only one request is allowed at a time. */ @@ -106,7 +116,11 @@ class PasskeyWebListener( methodTag, "Received WebAuthN request of type: ${webAuthNMessage.type} from origin: $sourceOrigin" ) - val passkeyReplyChannel = PasskeyReplyChannel(javaScriptReplyProxy, webAuthNMessage.type) + val passkeyReplyChannel = PasskeyReplyChannel( + replyProxy = javaScriptReplyProxy, + requestType = webAuthNMessage.type, + otelContext = otelContext + ) // Only allow one request at a time. if (havePendingRequest.get()) { @@ -228,7 +242,10 @@ class PasskeyWebListener( messageData: String?, javaScriptReplyProxy: JavaScriptReplyProxy ): WebAuthNMessage? { - val passkeyReplyChannel = PasskeyReplyChannel(javaScriptReplyProxy) + val passkeyReplyChannel = PasskeyReplyChannel( + replyProxy = javaScriptReplyProxy, + otelContext = otelContext + ) return runCatching { if (messageData.isNullOrBlank()) { throw ClientException(ClientException.MISSING_PARAMETER, "Message data is null or blank") @@ -329,8 +346,11 @@ class PasskeyWebListener( INTERFACE_NAME, PasskeyOriginRulesManager.getAllowedOriginRules(), PasskeyWebListener( - coroutineScope = CoroutineScope(Dispatchers.Default), - credentialManagerHandler = CredentialManagerHandler(activity) + // CredentialManager must be called on the main thread (it shows system UI), + // so the coroutine scope must use Dispatchers.Main. + coroutineScope = CoroutineScope(Dispatchers.Main), + credentialManagerHandler = CredentialManagerHandler(activity), + otelContext = (activity as? AuthorizationActivity)?.otelContext ) ) diff --git a/common/src/test/java/com/microsoft/identity/common/internal/fido/WebAuthnJsonUtilTest.kt b/common/src/test/java/com/microsoft/identity/common/internal/fido/WebAuthnJsonUtilTest.kt index 86a4f39d01..074f8142e1 100644 --- a/common/src/test/java/com/microsoft/identity/common/internal/fido/WebAuthnJsonUtilTest.kt +++ b/common/src/test/java/com/microsoft/identity/common/internal/fido/WebAuthnJsonUtilTest.kt @@ -22,9 +22,10 @@ // THE SOFTWARE. package com.microsoft.identity.common.internal.fido -import com.microsoft.identity.common.internal.fido.WebAuthnJsonUtil.Companion.base64UrlEncoded +import com.microsoft.identity.common.internal.fido.WebAuthnJsonUtil.base64UrlEncoded import org.json.JSONException import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -151,4 +152,67 @@ class WebAuthnJsonUtilTest { fun testBase64UrlEncoded_RandomString() { assertEquals(expectedEncodedRandomString, randomString.base64UrlEncoded()) } + + // createCredentialResponse.json test data + // clientDataJSON decodes to: + // {"type":"webauthn.create","challenge":"...","origin":"android:apk-key-hash:E8lSX1zJMPBZ9G_zpnfxfmfh-d0q2qEYz-2bgNeUKCU","androidPackageName":"com.microsoft.identity.testuserapp"} + val createCredentialResponseJson = """{"rawId":"_mqxqFyisYcQDo7sTf5Ggw","authenticatorAttachment":"platform","type":"public-key","id":"_mqxqFyisYcQDo7sTf5Ggw","response":{"clientDataJSON":"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiTFVOeGMwZ3FXaXBwYTBacmRGTmpiMU5OUkc1UmNqaHZlRFUxWkVOUFZGWkZVbVJrV0hSNFFXSnRWbWN6Wld4UldGVkxZblpvUWpkalJrNXRaa2g1VkhNek9FUjNaM05pVG5oVFVtTnRjRzgwZVVSRVUzQk5SVlpuYzBKc2QxUnlNVkZ4TlhOcVFqSndTMnBSS2ciLCJvcmlnaW4iOiJhbmRyb2lkOmFway1rZXktaGFzaDpFOGxTWDF6Sk1QQlo5R196cG5meGZtZmgtZDBxMnFFWXotMmJnTmVVS0NVIiwiYW5kcm9pZFBhY2thZ2VOYW1lIjoiY29tLm1pY3Jvc29mdC5pZGVudGl0eS50ZXN0dXNlcmFwcCJ9","attestationObject":"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViUNWye1KCTIblpXx6vkYID8bVfaJ2mH7yWGEwVfdpoDIFdAAAAAOqbjWZNAR0hPOS2tIy1ddQAEP5qsahcorGHEA6O7E3-RoOlAQIDJiABIVgg2wYC7isQOus7OjKigGo_J37T42oJq0SROrLhqn-53AgiWCAN3Z596TH_Lh9BeAdZinza_vXPWfb90QzUcK-vpipqKQ","transports":["internal","hybrid"],"authenticatorData":"NWye1KCTIblpXx6vkYID8bVfaJ2mH7yWGEwVfdpoDIFdAAAAAOqbjWZNAR0hPOS2tIy1ddQAEP5qsahcorGHEA6O7E3-RoOlAQIDJiABIVgg2wYC7isQOus7OjKigGo_J37T42oJq0SROrLhqn-53AgiWCAN3Z596TH_Lh9BeAdZinza_vXPWfb90QzUcK-vpipqKQ","publicKeyAlgorithm":-7,"publicKey":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2wYC7isQOus7OjKigGo_J37T42oJq0SROrLhqn-53AgN3Z596TH_Lh9BeAdZinza_vXPWfb90QzUcK-vpipqKQ"},"clientExtensionResults":{"credProps":{"rk":true}}}""" + val expectedOriginFromCreateCredentialResponse = "android:apk-key-hash:E8lSX1zJMPBZ9G_zpnfxfmfh-d0q2qEYz-2bgNeUKCU" + + // attestationObject from createCredentialResponseJson decodes to AAGUID ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4 + val expectedAaguidFromCreateCredentialResponse = "ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4" + val attestationObjectFromCreateCredential = "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViUNWye1KCTIblpXx6vkYID8bVfaJ2mH7yWGEwVfdpoDIFdAAAAAOqbjWZNAR0hPOS2tIy1ddQAEP5qsahcorGHEA6O7E3-RoOlAQIDJiABIVgg2wYC7isQOus7OjKigGo_J37T42oJq0SROrLhqn-53AgiWCAN3Z596TH_Lh9BeAdZinza_vXPWfb90QzUcK-vpipqKQ" + + @Test + fun testExtractOriginFromRegistrationResponse_fromCreateCredentialResponse() { + val result = WebAuthnJsonUtil.extractOriginFromRegistrationResponse(createCredentialResponseJson) + assertEquals(expectedOriginFromCreateCredentialResponse, result) + } + + @Test + fun testExtractOriginFromRegistrationResponse_invalidJson_returnsNull() { + val result = WebAuthnJsonUtil.extractOriginFromRegistrationResponse("not valid json") + assertNull(result) + } + + @Test + fun testExtractOriginFromRegistrationResponse_missingResponseKey_returnsNull() { + val result = WebAuthnJsonUtil.extractOriginFromRegistrationResponse("""{"id":"abc","type":"public-key"}""") + assertNull(result) + } + + @Test + fun testExtractAaguidFromRegistrationResponse_fromCreateCredentialResponse() { + val result = WebAuthnJsonUtil.extractAaguidFromRegistrationResponse(createCredentialResponseJson) + assertEquals(expectedAaguidFromCreateCredentialResponse, result) + } + + @Test + fun testExtractAaguidFromRegistrationResponse_invalidJson_returnsNull() { + val result = WebAuthnJsonUtil.extractAaguidFromRegistrationResponse("not valid json") + assertNull(result) + } + + @Test + fun testExtractAaguidFromRegistrationResponse_missingResponseKey_returnsNull() { + val result = WebAuthnJsonUtil.extractAaguidFromRegistrationResponse("""{"id":"abc","type":"public-key"}""") + assertNull(result) + } + + @Test + fun testExtractAaguidFromAttestationObject_validAttestationObject_returnsExpectedAaguid() { + val result = WebAuthnJsonUtil.extractAaguidFromAttestationObject(attestationObjectFromCreateCredential) + assertEquals(expectedAaguidFromCreateCredentialResponse, result) + } + + @Test(expected = Exception::class) + fun testExtractAaguidFromAttestationObject_invalidBase64_throws() { + WebAuthnJsonUtil.extractAaguidFromAttestationObject("!!!not-valid-base64!!!") + } + + @Test(expected = Exception::class) + fun testExtractAaguidFromAttestationObject_missingAuthData_throws() { + // Valid base64url but CBOR with no authData key + WebAuthnJsonUtil.extractAaguidFromAttestationObject("oA") + } } 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 541ac36304..d1cc1cbf6b 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 @@ -161,5 +161,38 @@ class FidoConstants { * JSON key value of id in response of Webauthn JSON object. */ const val WEBAUTHN_RESPONSE_ID_JSON_KEY = "id" + + /** + * JSON key value of attestationObject in WebAuthn registration response JSON object. + */ + const val WEBAUTHN_REGISTRATION_ATTESTATION_OBJECT_JSON_KEY = "attestationObject" + + /** + * JSON key value of origin in WebAuthn registration response JSON object. + */ + const val WEBAUTHN_REGISTRATION_ORIGIN_JSON_KEY = "origin" + + /** + * Byte offset of the flags byte within authenticator data (`authData`) as defined by WebAuthn. + */ + const val WEBAUTHN_AUTHDATA_FLAGS_OFFSET = 32 + + /** + * Bit mask for the attested credential data flag (AT) in the authenticator data flags byte. + */ + const val WEBAUTHN_AUTHDATA_ATTESTED_CREDENTIAL_DATA_FLAG = 0x40 + + /** + * Byte offset of the AAGUID within the authenticator data (authData) of a WebAuthn + * attestation object, as defined by the WebAuthn spec + * (https://www.w3.org/TR/webauthn-2/#sctn-authenticator-data). + */ + const val WEBAUTHN_AUTHDATA_AAGUID_OFFSET = 37 + + /** + * Length in bytes of the AAGUID field within the authenticator data (authData), + * as defined by the WebAuthn spec. + */ + const val WEBAUTHN_AUTHDATA_AAGUID_LENGTH = 16 } } 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 88312c8b18..55861288f0 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 @@ -495,6 +495,16 @@ public enum AttributeName { */ passkey_dom_exception_name, + /** + * Origin extracted from the WebAuthn clientDataJSON response. + */ + passkey_origin, + + /** + * AAGUID of the authenticator, extracted from the attestation authenticatorData (create flow only). + */ + passkey_aaguid, + /** * Elapsed time (in milliseconds) spent in executing the save() method in BrokerOAuth2TokenCache. */