Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
2f6fc36
Add passkey support with CredentialManager and WebView integration
p3dr0rv Sep 22, 2025
a43bfef
debug
p3dr0rv Sep 23, 2025
a02b892
Log success response in PasskeyWebListener and update JavaScript mess…
p3dr0rv Sep 24, 2025
963d700
Merge branch 'dev' into pedroro/passkey-reg-prototype
p3dr0rv Sep 24, 2025
7e2e2f3
Merge branch 'dev' into pedroro/passkey-reg-prototype
p3dr0rv Oct 7, 2025
be90629
Enhance WebView message handling and update Gradle configurations
p3dr0rv Oct 8, 2025
ef52c35
Refactor Passkey protocol handling and enhance WebView integration
p3dr0rv Oct 10, 2025
150aea2
Add passkey registration flight control and update WebView header han…
p3dr0rv Oct 10, 2025
89e0f50
Refactor PasskeyWebListener and WebViewAuthorizationFragment for impr…
p3dr0rv Oct 10, 2025
a6ec873
Refactor CredentialManagerHandler to improve passkey creation and ret…
p3dr0rv Oct 10, 2025
83b9c49
Refactor imports in CredentialManagerHandler for improved clarity and…
p3dr0rv Oct 11, 2025
578242c
Implement Passkey functionality with WebAuthn support
p3dr0rv Oct 16, 2025
5c8bdbb
Refactor PasskeyReplyChannel and related components for improved erro…
p3dr0rv Oct 17, 2025
036fd39
Refactor build.gradle to set project archivesBaseName correctly; upda…
p3dr0rv Oct 17, 2025
8304ba5
Add WebView support for Passkey functionality; update dependencies an…
p3dr0rv Oct 17, 2025
8b31fd0
Update minified JavaScript code in PasskeyWebListener; add detailed i…
p3dr0rv Oct 18, 2025
6f5e568
Merge branch 'dev' into pedroro/passkey-reg-prototype
p3dr0rv Oct 21, 2025
1fc5328
Refactor request header handling in WebViewAuthorizationFragment; ext…
p3dr0rv Oct 21, 2025
89f6544
Update dependencies in build.gradle; replace hardcoded webkit version…
p3dr0rv Oct 21, 2025
f172ccb
Remove passkey feature flight toggle; set passkey registration to dis…
p3dr0rv Oct 23, 2025
d87a5cc
Enhance Passkey protocol header handling in WebViewAuthorizationFragm…
p3dr0rv Oct 23, 2025
0c0cbfc
Merge branch 'dev' into pedroro/passkey-reg-prototype
p3dr0rv Oct 23, 2025
f206ee5
Refactor createPasskey and getPasskey methods in CredentialManagerHan…
p3dr0rv Oct 23, 2025
2fb63ad
Merge branch 'pedroro/passkey-reg-prototype' of https://github.com/Az…
p3dr0rv Oct 23, 2025
7117010
Add passkey registration support for WebView in changelog
p3dr0rv Oct 23, 2025
8e13fde
Update default dependencies size to 16 MB in build.gradle
p3dr0rv Oct 23, 2025
6c20ed8
Add support for dynamic passkey protocol version validation
p3dr0rv Oct 23, 2025
8bbf3d4
Refactor code for improved readability and formatting in PasskeyReply…
p3dr0rv Oct 23, 2025
d611ac0
Enhance passkey functionality by adding span context retrieval, impro…
p3dr0rv Oct 24, 2025
3a7480b
Add OpenTelemetry support for passkey operations, enhancing error tra…
p3dr0rv Oct 27, 2025
f038392
Refactor WebViewAuthorizationFragment for improved code clarity and m…
p3dr0rv Oct 28, 2025
49c9101
Refactor WebViewAuthorizationFragment for improved code clarity and m…
p3dr0rv Oct 28, 2025
2b1088d
Update common/src/main/java/com/microsoft/identity/common/internal/pr…
p3dr0rv Oct 29, 2025
7c0d8ec
Update common/src/main/java/com/microsoft/identity/common/internal/fi…
p3dr0rv Oct 29, 2025
be46370
Update common/src/main/java/com/microsoft/identity/common/internal/pr…
p3dr0rv Oct 29, 2025
999011b
update WebViewAuthorizationFragment logging methods for improved privacy
p3dr0rv Oct 29, 2025
12a2d84
Add WebAuthn JavaScript bridge and enhance PasskeyWebListener for deb…
p3dr0rv Nov 1, 2025
d4c5cf3
Update common/src/main/assets/js-bridge.js
p3dr0rv Nov 3, 2025
2bf51b7
Update WebAuthn interface name and modify user verification promise r…
p3dr0rv Nov 3, 2025
eda9cca
Merge branch 'dev' into pedroro/passkey-reg-prototype
p3dr0rv Nov 4, 2025
1a9890e
Merge branch 'pedroro/passkey-reg-prototype' into pedroro/passkey-tel…
p3dr0rv Nov 4, 2025
78a51db
Enhance JsScriptRecord to validate sovereign cloud URLs and require '…
p3dr0rv Nov 5, 2025
3de6cc4
Enhance PasskeyReplyChannel and PasskeyWebListener for improved error…
p3dr0rv Nov 6, 2025
475e918
Add JsScriptRecord validation for sovereign cloud URLs and implement …
p3dr0rv Nov 6, 2025
724e181
Merge branch 'pedroro/passkey-reg-prototype' into pedroro/passkey-tel…
p3dr0rv Nov 6, 2025
0609a46
Update common/src/main/java/com/microsoft/identity/common/internal/ui…
p3dr0rv Nov 6, 2025
bb3957e
Update common/src/main/java/com/microsoft/identity/common/internal/ui…
p3dr0rv Nov 6, 2025
00b8a82
Update common/src/main/java/com/microsoft/identity/common/internal/pr…
p3dr0rv Nov 6, 2025
827c26f
Refactor PasskeyReplyChannel error handling and logging; remove redun…
p3dr0rv Nov 6, 2025
4ffe693
Merge branch 'dev' into pedroro/passkey-telemetry
p3dr0rv Nov 6, 2025
55238fd
Refactor PasskeyWebListener error handling to use ClientException for…
p3dr0rv Nov 6, 2025
9260512
Update error message assertion in PasskeyWebListenerTest for clarity …
p3dr0rv Nov 6, 2025
a04ccc8
Update changelog to reflect addition of OpenTelemetry support for pas…
p3dr0rv Nov 6, 2025
d6be3f5
Merge branch 'dev' into pedroro/passkey-telemetry
p3dr0rv Nov 7, 2025
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
@@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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


/**
Expand All @@ -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"
Expand Down Expand Up @@ -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

/**
Expand All @@ -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
Expand Down Expand Up @@ -131,41 +141,70 @@ 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.
*
* Maps credential exceptions to appropriate DOMException types.
*
* @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
}
}

/**
Expand Down Expand Up @@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)
}
}
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<JsScriptRecord> mOnPageStartedScripts = new ArrayList<>();
Expand All @@ -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;
}

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

// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ==========
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to make sure, we should not expect to see the type be authentication until support is extended on the ESTS side to allow authentication, right? auth will be represented by the telemetry set for the legacy fido classes?

Copy link
Contributor Author

@p3dr0rv p3dr0rv Nov 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, all operations should remain of type "registration" until ESTS decides to update their code to include authentication as well. At that point, a new type will be introduced. The idea of defining it now is simply to be prepared for when it’s needed. All authentication requests will continue to stay within the Fido span.


/**
* Passkey DOM exception name (if any).
*/
passkey_dom_exception_name,
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ public enum SpanName {
GetAllSsoTokens,
ProcessWebCpEnrollmentRedirect,
ProcessWebCpAuthorizeUrlRedirect,
PasskeyWebListener,
PersistToStorageAsync,
InstallCertOnWpj
}
Loading