Skip to content

Commit 8d611f4

Browse files
p3dr0rvCopilot
andauthored
Add OpenTelemetry support for passkey operations, Fixes AB#3412004 (AzureAD#2795)
This pull request introduces OpenTelemetry tracing to the passkey (WebAuthN) flow, ensuring that all interactions with the JavaScript reply channel and WebView client are properly traced for observability. It also refactors error handling in the passkey reply channel to use exceptions rather than plain strings, and propagates the tracing context (`SpanContext`) throughout the relevant classes. Additionally, the pull request cleans up and simplifies some internal logic and improves test coverage. [AB#3412004](https://identitydivision.visualstudio.com/fac9d424-53d2-45c0-91b5-ef6ba7a6bf26/_workitems/edit/3412004) --------- Co-authored-by: Copilot <[email protected]>
1 parent 451be4c commit 8d611f4

File tree

9 files changed

+117
-77
lines changed

9 files changed

+117
-77
lines changed

changelog.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
vNext
22
----------
3+
- [MINOR] Add OpenTelemetry support for passkey operations (#2795)
34
- [MINOR] Add passkey registration support for WebView (#2769)
45
- [MINOR] Add callback for OneAuth for measuring Broker Discovery Client Perf (#2796)
56
- [MINOR] Add new span name for DELEGATION_CERT_INSTALL's telemetry (#2790)

common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannel.kt

Lines changed: 64 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,14 @@ import androidx.credentials.exceptions.GetCredentialProviderConfigurationExcepti
3333
import androidx.credentials.exceptions.GetCredentialUnknownException
3434
import androidx.credentials.exceptions.NoCredentialException
3535
import androidx.webkit.JavaScriptReplyProxy
36+
import com.microsoft.identity.common.java.opentelemetry.AttributeName
37+
import com.microsoft.identity.common.java.opentelemetry.OTelUtility
38+
import com.microsoft.identity.common.java.opentelemetry.SpanExtension
39+
import com.microsoft.identity.common.java.opentelemetry.SpanName
3640
import com.microsoft.identity.common.logging.Logger
41+
import io.opentelemetry.api.trace.SpanContext
42+
import io.opentelemetry.api.trace.StatusCode
3743
import org.json.JSONObject
38-
import kotlin.jvm.Throws
3944

4045

4146
/**
@@ -48,7 +53,8 @@ import kotlin.jvm.Throws
4853
*/
4954
class PasskeyReplyChannel(
5055
private val replyProxy: JavaScriptReplyProxy,
51-
private val requestType: String = "unknown"
56+
private val requestType: String = "unknown",
57+
private val spanContext: SpanContext? = null
5258
) {
5359
companion object {
5460
const val TAG = "PasskeyReplyChannel"
@@ -78,8 +84,12 @@ class PasskeyReplyChannel(
7884
* Sealed class representing messages sent to JavaScript.
7985
*/
8086
sealed class ReplyMessage {
87+
// Message type (e.g., "create", "get").
8188
abstract val type: String
89+
// Message status ("success" or "error").
8290
abstract val status: String
91+
// Message data as a JSON object.
92+
// Either credential data for success or {domExceptionMessage, domExceptionName} for error.
8393
abstract val data: JSONObject
8494

8595
/**
@@ -102,8 +112,8 @@ class PasskeyReplyChannel(
102112
* @property type Request type that failed.
103113
*/
104114
class Error(
105-
private val domExceptionMessage: String,
106-
private val domExceptionName: String = DOM_EXCEPTION_NOT_ALLOWED_ERROR,
115+
val domExceptionMessage: String,
116+
val domExceptionName: String = DOM_EXCEPTION_NOT_ALLOWED_ERROR,
107117
override val type: String
108118
) : ReplyMessage() {
109119
override val status = ERROR_STATUS
@@ -131,41 +141,70 @@ class PasskeyReplyChannel(
131141
*
132142
* @param json JSON string containing the credential response.
133143
*/
144+
@SuppressLint("RequiresFeature", "Only called when feature is available")
134145
fun postSuccess(json: String) {
135146
val methodTag = "$TAG:postSuccess"
136-
send(ReplyMessage.Success(json, requestType))
137-
Logger.info(methodTag, "RequestType: $requestType, was successful.")
138-
}
139-
140-
/**
141-
* Posts an error message with a custom error description.
142-
*
143-
* @param errorMessage Error description to send.
144-
*/
145-
fun postError(errorMessage: String) {
146-
postErrorInternal(
147-
ReplyMessage.Error(domExceptionMessage = errorMessage, type = requestType)
147+
val span = OTelUtility.createSpanFromParent(
148+
SpanName.PasskeyWebListener.name,
149+
spanContext
148150
)
151+
152+
try {
153+
SpanExtension.makeCurrentSpan(span).use {
154+
val successMessage = ReplyMessage.Success(json, requestType).toString()
155+
replyProxy.postMessage(successMessage)
156+
Logger.info(methodTag, "RequestType: $requestType was successful.")
157+
span.setAttribute(AttributeName.passkey_operation_type.name, requestType)
158+
span.setStatus(StatusCode.OK)
159+
}
160+
} catch (throwable: Throwable) {
161+
span.setStatus(StatusCode.ERROR)
162+
span.setAttribute(AttributeName.passkey_operation_type.name, requestType)
163+
span.recordException(throwable)
164+
Logger.error(methodTag, "Reply message failed", throwable)
165+
throw throwable
166+
} finally {
167+
span.end()
168+
}
149169
}
150170

171+
172+
173+
151174
/**
152175
* Posts an error message based on a thrown exception.
153176
*
154177
* Maps credential exceptions to appropriate DOMException types.
155178
*
156179
* @param throwable Exception to convert and send.
157180
*/
181+
@SuppressLint("RequiresFeature", "Only called when feature is available")
158182
fun postError(throwable: Throwable) {
159-
postErrorInternal(throwableToErrorMessage(throwable))
160-
}
161-
162-
/**
163-
* Internal method to send error messages and log them.
164-
*/
165-
private fun postErrorInternal(errorMessage: ReplyMessage.Error) {
166183
val methodTag = "$TAG:postError"
167-
send(errorMessage)
168-
Logger.error(methodTag, "RequestType: $requestType, failed with error: $errorMessage", null)
184+
val span = OTelUtility.createSpanFromParent(
185+
SpanName.PasskeyWebListener.name,
186+
spanContext
187+
)
188+
189+
try {
190+
SpanExtension.makeCurrentSpan(span).use {
191+
val errorMessage = throwableToErrorMessage(throwable)
192+
replyProxy.postMessage(errorMessage.toString())
193+
span.setAttribute(AttributeName.passkey_operation_type.name, requestType)
194+
span.setAttribute(AttributeName.passkey_dom_exception_name.name, errorMessage.domExceptionName)
195+
span.setStatus(StatusCode.ERROR)
196+
span.recordException(throwable)
197+
Logger.error(methodTag, "RequestType: $requestType failed with error: $errorMessage", null)
198+
}
199+
} catch (unexpectedException: Throwable) {
200+
span.setStatus(StatusCode.ERROR)
201+
span.recordException(unexpectedException)
202+
span.setAttribute(AttributeName.passkey_operation_type.name, requestType)
203+
Logger.error(methodTag, "Reply message failed", unexpectedException)
204+
throw unexpectedException
205+
} finally {
206+
span.end() // Always end the span
207+
}
169208
}
170209

171210
/**
@@ -211,18 +250,4 @@ class PasskeyReplyChannel(
211250
type = requestType
212251
)
213252
}
214-
215-
/**
216-
* Sends a message to JavaScript via the reply proxy.
217-
*/
218-
@SuppressLint("RequiresFeature", "Only called when feature is available")
219-
private fun send(message: ReplyMessage) {
220-
val methodTag = "$TAG:send"
221-
try {
222-
replyProxy.postMessage(message.toString())
223-
} catch (t: Throwable) {
224-
Logger.error(methodTag, "Reply message failed", t)
225-
throw t
226-
}
227-
}
228253
}

common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListener.kt

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,14 +110,24 @@ class PasskeyWebListener(
110110

111111
// Only allow one request at a time.
112112
if (havePendingRequest.get()) {
113-
passkeyReplyChannel.postError("Request already in progress")
113+
passkeyReplyChannel.postError(
114+
ClientException(
115+
ClientException.REQUEST_IN_PROGRESS,
116+
"A WebAuthN request is already in progress."
117+
)
118+
)
114119
return
115120
}
116121
havePendingRequest.set(true)
117122

118123
// Only allow requests from the main frame.
119124
if (!isMainFrame) {
120-
passkeyReplyChannel.postError("Requests from iframes are not supported")
125+
passkeyReplyChannel.postError(
126+
ClientException(
127+
ClientException.UNSUPPORTED_OPERATION,
128+
"WebAuthN requests from iframes are not supported."
129+
)
130+
)
121131
havePendingRequest.set(false)
122132
return
123133
}
@@ -143,7 +153,12 @@ class PasskeyWebListener(
143153
}
144154

145155
else -> {
146-
passkeyReplyChannel.postError("Unknown request type: ${webAuthNMessage.type}")
156+
passkeyReplyChannel.postError(
157+
ClientException(
158+
ClientException.UNSUPPORTED_OPERATION,
159+
"Unsupported WebAuthN request type: ${webAuthNMessage.type}"
160+
)
161+
)
147162
havePendingRequest.set(false)
148163
}
149164
}
@@ -167,8 +182,12 @@ class PasskeyWebListener(
167182
if (publicKeyCredential != null) {
168183
reply.postSuccess(publicKeyCredential.authenticationResponseJson)
169184
} else {
170-
reply.postError("Unexpected credential type: ${credentialResponse.credential.javaClass.name}")
171-
}
185+
reply.postError(
186+
ClientException(
187+
ClientException.UNSUPPORTED_OPERATION,
188+
"Retrieved credential is not a PublicKeyCredential."
189+
)
190+
) }
172191
}
173192
.onFailure { throwable ->
174193
reply.postError(throwable)

common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,7 @@ public class AzureActiveDirectoryWebViewClient extends OAuth2WebViewClient {
141141
private boolean mAuthUxJavaScriptInterfaceAdded = false;
142142
// Determines whether to handle WebCP requests in the WebView in brokerless scenarios.
143143
private final boolean mIsWebViewWebCpEnabledInBrokerlessCase;
144-
145-
144+
private final SpanContext mSpanContext;
146145
private final String mUtid;
147146

148147
private final List<JsScriptRecord> mOnPageStartedScripts = new ArrayList<>();
@@ -159,6 +158,7 @@ public AzureActiveDirectoryWebViewClient(@NonNull final Activity activity,
159158
mCertBasedAuthFactory = new CertBasedAuthFactory(activity);
160159
mSwitchBrowserRequestHandler = switchBrowserRequestHandler;
161160
mUtid = utid;
161+
mSpanContext = activity instanceof AuthorizationActivity ? ((AuthorizationActivity) getActivity()).getSpanContext() : null;
162162
mIsWebViewWebCpEnabledInBrokerlessCase = isWebViewWebCpEnabledInBrokerlessCase;
163163
}
164164

@@ -1022,9 +1022,7 @@ private void processNonceAndReAttachHeaders(@NonNull final WebView view, @NonNul
10221022
AttributeName.is_sso_nonce_found_in_ests_request.name(), nonceQueryParam != null
10231023
);
10241024
if (nonceQueryParam != null) {
1025-
final SpanContext spanContext = getActivity() instanceof AuthorizationActivity ? ((AuthorizationActivity) getActivity()).getSpanContext() : null;
1026-
final Span span = spanContext != null ?
1027-
OTelUtility.createSpanFromParent(SpanName.ProcessNonceFromEstsRedirect.name(), spanContext) : OTelUtility.createSpan(SpanName.ProcessNonceFromEstsRedirect.name());
1025+
final Span span = OTelUtility.createSpanFromParent(SpanName.ProcessNonceFromEstsRedirect.name(), mSpanContext);
10281026
try (final Scope scope = SpanExtension.makeCurrentSpan(span)) {
10291027
final NonceRedirectHandler nonceRedirect = new NonceRedirectHandler(view, mRequestHeaders, span);
10301028
nonceRedirect.processChallenge(new URL(url));
@@ -1063,9 +1061,7 @@ private void processWebCpAuthorize(@NonNull final WebView view, @NonNull final S
10631061
private void processCrossCloudRedirect(@NonNull final WebView view, @NonNull final String url) {
10641062
final String methodTag = TAG + ":processCrossCloudRedirect";
10651063

1066-
final SpanContext spanContext = getActivity() instanceof AuthorizationActivity ? ((AuthorizationActivity) getActivity()).getSpanContext() : null;
1067-
final Span span = spanContext != null ?
1068-
OTelUtility.createSpanFromParent(SpanName.ProcessCrossCloudRedirect.name(), spanContext) : OTelUtility.createSpan(SpanName.ProcessCrossCloudRedirect.name());
1064+
final Span span = OTelUtility.createSpanFromParent(SpanName.ProcessCrossCloudRedirect.name(), mSpanContext);
10691065
final ReAttachPrtHeaderHandler reAttachPrtHeaderHandler = new ReAttachPrtHeaderHandler(view, mRequestHeaders, span);
10701066
reAttachPrtHeader(url, reAttachPrtHeaderHandler, view, methodTag, span);
10711067
}
@@ -1212,9 +1208,7 @@ private String getBrokerAppPackageNameFromUrl(@NonNull final String url) {
12121208
* @return Created {@link Span}
12131209
*/
12141210
private Span createSpanWithAttributesFromParent(@NonNull final String spanName) {
1215-
final SpanContext spanContext = getActivity() instanceof AuthorizationActivity ? ((AuthorizationActivity) getActivity()).getSpanContext() : null;
1216-
final Span span = spanContext != null ?
1217-
OTelUtility.createSpanFromParent(spanName, spanContext) : OTelUtility.createSpan(spanName);
1211+
final Span span = OTelUtility.createSpanFromParent(spanName, mSpanContext);
12181212
if (mUtid != null) {
12191213
span.setAttribute(AttributeName.tenant_id.name(), mUtid);
12201214
}

common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyReplyChannelTest.kt

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -87,26 +87,6 @@ class PasskeyReplyChannelTest {
8787
assertEquals(0, dataObject.length())
8888
}
8989

90-
@Test
91-
fun `postError with string sends correct error format`() {
92-
// Given
93-
val errorMessage = "Test error message"
94-
val messageSlot = slot<String>()
95-
96-
// When
97-
passkeyReplyChannel.postError(errorMessage)
98-
99-
// Then
100-
verify { mockReplyProxy.postMessage(capture(messageSlot)) }
101-
102-
val messageObject = JSONObject(messageSlot.captured)
103-
val dataObject = messageObject.getJSONObject(PasskeyReplyChannel.DATA_KEY)
104-
105-
assertEquals(errorMessage, dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_MESSAGE_KEY))
106-
assertEquals(PasskeyReplyChannel.DOM_EXCEPTION_NOT_ALLOWED_ERROR,
107-
dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_NAME_KEY))
108-
}
109-
11090
@Test
11191
fun `postError with cancellation exception returns NotAllowedError`() {
11292
// Given

common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/PasskeyWebListenerTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ class PasskeyWebListenerTest {
304304
val responseObject = JSONObject(messageSlot.captured)
305305
assertEquals(PasskeyReplyChannel.ERROR_STATUS, responseObject.getString(PasskeyReplyChannel.STATUS_KEY))
306306
val dataObject = responseObject.getJSONObject(PasskeyReplyChannel.DATA_KEY)
307-
assertTrue(dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_MESSAGE_KEY).contains("Unknown request type"))
307+
assertTrue(dataObject.getString(PasskeyReplyChannel.DOM_EXCEPTION_MESSAGE_KEY).contains("Unsupported WebAuthN request type: unknown_type"))
308308
}
309309

310310
// ========== Frame Origin Tests ==========

common4j/src/main/com/microsoft/identity/common/java/exception/ClientException.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,16 @@ public class ClientException extends BaseException {
133133
*/
134134
public static final String UNSUPPORTED_ENCODING = "unsupported_encoding";
135135

136+
/**
137+
* The operation is not supported.
138+
*/
139+
public static final String UNSUPPORTED_OPERATION = "unsupported_operation";
140+
141+
/**
142+
* The request is already in progress.
143+
*/
144+
public static final String REQUEST_IN_PROGRESS = "request_in_progress";
145+
136146
/**
137147
* The designated crypto alg is not supported.
138148
*/

common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,5 +487,15 @@ public enum AttributeName {
487487
/**
488488
* Records if current flow is in webcp flow.
489489
*/
490-
is_in_web_cp_flow
490+
is_in_web_cp_flow,
491+
492+
/**
493+
* Passkey operation type (e.g., registration, authentication).
494+
*/
495+
passkey_operation_type,
496+
497+
/**
498+
* Passkey DOM exception name (if any).
499+
*/
500+
passkey_dom_exception_name,
491501
}

common4j/src/main/com/microsoft/identity/common/java/opentelemetry/SpanName.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ public enum SpanName {
7272
GetAllSsoTokens,
7373
ProcessWebCpEnrollmentRedirect,
7474
ProcessWebCpAuthorizeUrlRedirect,
75+
PasskeyWebListener,
7576
PersistToStorageAsync,
7677
InstallCertOnWpj
7778
}

0 commit comments

Comments
 (0)