Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------
Expand Down
1 change: 1 addition & 0 deletions common/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public class AuthorizationActivity extends DualScreenActivity {
@Accessors(prefix = "m")
private SpanContext mSpanContext;

@Getter

@Accessors(prefix = "m")
private Context mOtelContext;

Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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


Expand All @@ -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"
Expand All @@ -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
)
}

/**
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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.
Expand All @@ -183,7 +278,7 @@ class PasskeyReplyChannel(
val methodTag = "$TAG:postError"
val span = OTelUtility.createSpanFromParent(
SpanName.PasskeyWebListener.name,
spanContext
resolveParentSpanContext(methodTag)
)

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. */
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
)
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
}
Loading
Loading