[common] Add non-mutating log_telemetry bridge action (errorCode), Fixes AB#3688631 - #3197
Conversation
|
✅ Work item link check complete. Description contains link AB#3688631 to an Azure Boards work item. |
…#3688631
Add a dedicated, non-mutating `log_telemetry` action to AuthUxJavaScriptInterface so
Auth UX page JS can push an opaque server error code to the native host over the existing
postMessageToBroker bridge, without touching the number-match / write_data device-store
path (H3).
Implemented against the Auth UX design-doc wire format:
{ "correlationID": ..., "action_name": "log_telemetry", "action_component": "host",
"params": { "v": 1, "sessionID": ..., "errorCode": 530003, "pageId": ..., "trackingId": ... } }
- AuthUxJavaScriptInterface: dispatch by top-level action_name == "log_telemetry" (the action
carries no params.operation) via a new ActionNames.LOG_TELEMETRY; add a minimal
AuthUxTelemetrySink seam and an optional (@jvmoverloads) telemetrySink constructor param that
keeps existing no-arg construction source-compatible. The branch reads errorCode and forwards
it to the sink; absent/empty errorCode is a safe no-op. number_matching still dispatches off
params.operation, unchanged.
- AuthUxJsonPayload: model the full params contract with @SerializedName — v (version), sessionID,
errorCode (opaque; captured as a string even when sent as a JSON number), pageId, trackingId;
all fields nullable.
- Forward-compat: new top-level and params key/value pairs do not break parsing or dispatch
(custom deserializer reads only known top-level keys; Gson ignores unknown params fields).
- Tests: exact design-doc payload; errorCode present/absent; unknown top-level + params fields
tolerated; sink invoked exactly once; no-op on missing errorCode; number_matching regression-safe
and never routed to the sink.
The onboarding-blob append (blocking-errors list + non-blocking exclusion) and consumption of
pageId/trackingId/sessionID/v are handled downstream in AB#3688632.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
fdb7ff3 to
d9e5683
Compare
… context, AB#3688631
Blocking fixes:
- Dispatch on action_name BEFORE params.operation. Previously a log_telemetry
message that smuggled params.operation=number_matching took the number-match
branch and mutated the device store, contradicting the H3 invariant the code
comment claimed. Regression test fails without the reordering.
- params.v retyped Int? -> String?. Gson throws on a type mismatch, so a version
of "1.0" or "v2" dropped the ENTIRE message including errorCode. The one field
designed to change over time must never be a parse cliff.
Hardening:
- Validate errorCode against ^[A-Za-z0-9_-]{1,32}$ BEFORE it reaches any log
line, so a crafted value cannot inject newlines into logs or push an unbounded
string into the uploaded onboarding blob. Mirrors the NumberMatchHelper
precedent on this same bridge.
- Dedupe and cap forwarded codes per bridge instance (max 10) so a looping page
cannot bloat the blob.
- Bound page-supplied action/operation strings echoed into logs.
Observability:
- Log the forwarded / rejected / duplicate / capped / no-sink-wired outcomes
distinguishably, each with the correlation id inline.
- Record the code on a new authux_js_error_code span attribute so the path is
queryable in android_spans without waiting for the blob to land.
- Give the sink invocation its own try/catch so a throwing sink is no longer
misreported as "Unknown error occurred while processing the payload".
API:
- Sink now takes an AuthUxTelemetryEvent (correlation id, error code, session /
page / tracking ids, version) instead of a bare error code. correlationId is
the Kusto/DRI join key and was being discarded at the seam; passing the
context now avoids a source-breaking SAM change in a follow-up PBI.
- Document the JavaBridge calling thread and the thread-safety requirement on
AuthUxTelemetrySink.
Also: document the log_telemetry wire format on receiveAuthUxMessage (the KDoc
described only number-match), use the unused SerializedNames.PARAMS constant,
and add the reviewer's missing test cases (28 bridge + 10 payload, all passing).
…tize correlationId, AB#3688631 A (blocking) record-before-forward: a code was added to forwardedErrorCodes before the sink was null-checked or invoked, so a code that nothing consumed was suppressed as a duplicate on retry and permanently lost -- while the bridge had already logged "Forwarded ...". The sink now returns whether it consumed the event; the code is recorded only on true, never on the no-sink path. A sink that throws counts as consumed (a throwing sink is a host defect, retrying cannot fix it, and treating it as retryable lets a looping page re-invoke it without bound). B (blocking) dedupe/cap scope: the bridge is rebuilt on every onPageStarted, so the per-instance state resets each navigation while the consumer lives for the whole request. Confirmed by our own device E2E, whose blob contained blocking_errors ["530003","530003"] from two page loads -- previously written off as a simulation artifact. The KDoc now states the per-page scope and its consequence plainly, a test pins the behaviour across two registrations, and request-wide de-duplication moves to the consumer, which owns the blob. C: the span attribute was set before the dedupe/cap checks, so it reported codes that were never forwarded. Moved after the forward decision, and the last-forwarded-wins overwrite semantics are documented. D: correlationID is page-controlled and was logged raw, then passed as the correlationID argument of five Logger calls -- common4j formats it verbatim, so the log-forging vector this PR closes for errorCode was still open here. Sanitized once immediately after parsing. E: the sink is no longer invoked while holding the internal lock, and the threading contract now states the guarantee the bridge actually provides (serialized per instance, on the JavaBridge thread) instead of asserting a requirement it cannot enforce. Host-owned state is made thread-safe in #3201. F: the version KDoc no longer claims "no cliff" -- a structured value still drops the message; documented as out of contract. Tests: 28 -> 34. The A fix is pinned by two tests that fail without it (expected:<[530003]> but was:<[]>).
|
✅ Work item link check complete. Description contains link AB#3688631 to an Azure Boards work item. |
…8631 N-B (regression from the last round): moving add() after consumption meant the distinct-code cap only ever counted CONSUMED codes, so the not-consumed and no-sink paths were unbounded -- a code that is never consumed is never recorded, so neither contains() nor the size check can ever fire for it. A page looping the same code while the recorder was unattached re-invoked the sink and re-logged without limit, and today every production message takes one of those two paths. The previous test hid this: it posted 15 codes against a cap of 10 and asserted only that none were consumed, never that the sink stopped being called. Added telemetryAttempts, counted before the outcome is known, so every path past validation is bounded (MAX_TELEMETRY_ATTEMPTS = 25, above the distinct-code cap so legitimate retries still get through). The test now asserts sink invocations are capped and fails without the guard (expected:<25> but was:<40>). N-A: a throw set consumed = true, which drove three decisions when it should drive one. Suppressing retry is right; also setting authux_js_error_code and logging "Forwarded ... to onboarding telemetry" was not -- nothing reached downstream telemetry, and the comment above setAttribute asserted exactly that could not happen. Replaced the boolean with a SinkOutcome tri-state (CONSUMED / NOT_CONSUMED / THREW): THREW suppresses retry only. N-C: the KDoc credited per-instance serialization to this class, but receiveAuthUxMessage takes no lock -- that is a WebView platform property (one JavaBridge thread per WebView). It matters because the dedupe/cap bookkeeping is now check-then-act across two critical sections with the sink call between them. Documented the gap, attributed the serialization correctly, and noted the state stays guarded so an off-thread caller could at worst cause a bounded duplicate forward rather than a corrupted set. Also renamed forwardedErrorCodes -> handledErrorCodes: it now holds codes that will not be offered again, which includes the throw case.
|
✅ Work item link check complete. Description contains link AB#3688631 to an Azure Boards work item. |
There was a problem hiding this comment.
Pull request overview
Adds a new non-mutating log_telemetry action to the Auth UX WebView JS bridge so Auth UX page JS can forward an opaque server error code to a host-provided telemetry sink, while keeping number_matching behavior isolated and unchanged.
Changes:
- Introduces
AuthUxTelemetryEvent+AuthUxTelemetrySinkand dispatchesaction_name == "log_telemetry"beforeparams.operation, forwarding validated error codes with context. - Extends the Auth UX payload model (
AuthUxParams) to support telemetry fields (v,errorCode,pageId,trackingId) with nullable fields and a string-typed schema version. - Adds
authux_js_error_codeOpenTelemetry attribute + expands unit tests and updateschangelog.txt.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java | Adds new span attribute authux_js_error_code for Auth UX error-code telemetry. |
| common/src/main/java/com/microsoft/identity/common/internal/broker/AuthUxJsonPayload.kt | Extends payload params model + deserializer constants to support telemetry fields. |
| common/src/main/java/com/microsoft/identity/common/internal/broker/AuthUxJavaScriptInterface.kt | Adds the log_telemetry dispatch path, sink seam, validation/dedupe/capping, and span attribute setting. |
| common/src/test/java/com/microsoft/identity/common/internal/broker/AuthUxJsonPayloadTest.kt | Adds deserialization tests for the log_telemetry wire shape and forward-compat cases. |
| common/src/test/java/com/microsoft/identity/common/internal/broker/AuthUxJavaScriptInterfaceTest.kt | Adds dispatch/behavioral tests for sink forwarding, retry eligibility, dedupe/caps, and safety invariants. |
| changelog.txt | Documents the new log_telemetry bridge behavior in vNext. |
…join key, AB#3688631
The attempt cap was checked AFTER the duplicate and distinct-code checks, and both
of those log and return -- so a page looping one already-handled code, or posting
new codes once the distinct-code cap was reached, never reached the counter and
could spam the log without bound. Same failure mode the counter was added to
prevent, one level up. Now checked and incremented first, so every path past
validation is bounded, and the cap message itself is logged once on the
transition rather than on every subsequent message.
correlationID was run through sanitizeForLog, which truncates at 64 characters.
It is also the telemetry join key forwarded to the sink, so truncating a longer
ID would silently break correlation. Split out sanitizeCorrelationId: strips
control characters (the log-forging concern) without changing length semantics,
cutting only past 128 characters, which no real correlation ID reaches. A GUID
now survives verbatim, pinned by a test.
Mirrored authux_js_error_code into broker4j's AttributeName enum, which already
carries authux_js_action_name / _action_component / _operation -- cross-repo
telemetry keys must stay aligned. Switched the JavaDoc to {@code} in both, since
Markdown backticks do not render in JavaDoc.
Made the log_telemetry test fixtures strict JSON (quoted keys). They are
described as matching the design-doc wire format, so they should be what a real
JSON.stringify sender emits rather than something only a lenient parser accepts.
Pre-existing number-match fixtures left alone -- not this PR's code.
Tests 37 -> 39. The ordering fix is pinned by a test that fails without it
(expected:<1> but was:<2>).
Repeated merges of an edited changelog line left four duplicate #3197 entries and split this PR across three separate lines. Collapsed to a single entry each.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (2)
common/src/main/java/com/microsoft/identity/common/internal/broker/AuthUxJavaScriptInterface.kt:233
- sanitizeCorrelationId() currently uses
value.map { ... }.joinToString(""), which walks and allocates a full copy of the correlation ID even though the result is truncated to 128 chars. Since correlationID is page-controlled, this can cause unnecessary large allocations/CPU work for oversized inputs.
Recommendation: sanitize incrementally and stop once MAX_CORRELATION_ID_LENGTH is reached, so processing stays bounded.
private fun sanitizeCorrelationId(value: String): String {
val flattened = value.map { if (it.isISOControl()) ' ' else it }.joinToString("")
return if (flattened.length <= MAX_CORRELATION_ID_LENGTH) {
flattened
} else {
common/src/main/java/com/microsoft/identity/common/internal/broker/AuthUxJavaScriptInterface.kt:216
- sanitizeForLog() builds a full-size copy of the untrusted page-supplied string via
value.replace(...)before truncating. A malicious/buggy page can send a very large value and trigger large allocations/CPU work on the JavaBridge thread (potential DoS) even though the log output is capped.
Recommendation: sanitize while iterating and stop once MAX_LOGGED_VALUE_LENGTH is reached (also replaces control chars), so work is bounded regardless of input size.
This issue also appears on line 229 of the same file.
val flattened = value.replace('\n', ' ').replace('\r', ' ')
return if (flattened.length <= MAX_LOGGED_VALUE_LENGTH) {
flattened
} else {
flattened.substring(0, MAX_LOGGED_VALUE_LENGTH) + "...(truncated)"
Both sanitize helpers transformed the whole page-supplied string and only then truncated it. sanitizeCorrelationId was the worse of the two: String.map produces a List<Char>, so it boxed every character of the input before joining. Replaced both with one sanitizeBounded(value, limit) that walks at most limit characters into a pre-sized StringBuilder. Work is now proportional to the output bound rather than to the size of the untrusted input. Note this bounds OUR processing, not the attacker's allocation -- the value is already fully materialized by the time the bridge is called (WebView marshals the payload across JNI and Gson parses it before we see it), so the DoS framing is weaker than it looks. It is still strictly better and trivially cheap. Unifying on the shared helper also means sanitizeForLog now strips all control characters instead of only CR/LF, so an ESC or NEL can no longer reach a log line either.
|
Fixed in 3104c92 — picked up both suppressed comments about Both were valid. One correction to the framing: this bounds our processing, not the attacker's allocation. By the time the bridge is invoked the value is already fully materialized — WebView marshals the whole payload across JNI and Gson parses it before Bonus from unifying on the shared helper: |
|
Overall, the change looks good. The new telemetry action is well isolated from the existing number-matching path, defensively validates page-controlled input, and has strong test coverage. I left a few minor comments around clarity, naming, and additional test coverage; none are blocking. |
…ed), AB#3708195 The broker builds an onboarding telemetry recorder per request from the seed, but the WebView that renders the interactive auth / remediation pages lives in a separate AuthorizationActivity created by the OS from an Intent. A live recorder cannot ride an Intent, so the WebView-side onboarding hooks were inert for brokered flows: steps observed in the WebView (MDM enrollment, Company Portal launch, broker install), the last loaded domain, and the Auth UX log_telemetry error code never reached the blob the broker finalizes and returns. Both components run in the broker :auth process, so this adds an in-process handoff keyed by the request correlationId. OnboardingRecorderRegistry (new) - Rejects DiagnosticContext.UNSET_CORRELATION_ID as a key. Every thread whose request context was never set carries that sentinel, and the authorization Intent extra is populated by reading the request-context map directly rather than through getThreadCorrelationId(), so the raw sentinel can reach the registry. It is shared by definition: accepting it would let two unrelated requests resolve to the same recorder and merge one flow's blocking errors into the other's uploaded blob. Rejecting it makes the feature inert (and logs a warning) instead of silently mis-attributing telemetry, which for a component whose whole purpose is correct attribution is the only acceptable failure. - Bounded, access-ordered map (cap 16). Entries must be unregistered on terminal outcome, and because this is process-static state in a process that lives as long as the device is up, a missed unregister would otherwise leak a recorder permanently - the recorder object and its collected steps / blocking errors, not an Activity, since OnboardingTelemetryRecorder holds only the application context. On overflow it evicts the least-recently-used entry and logs a warning, bounding the leak while surfacing the underlying bug. - Stores the concrete OnboardingTelemetryRecorder because the WebView client's hooks (e.g. setLastLoadedDomain) use methods not on the common4j interface. WebViewAuthorizationFragment - Resolves the recorder in onCreateView and attaches it to the WebView client before initializeAuthUxJavaScriptApi(...). No-op when the request seeded no recorder, which is the MSAL-client path. - Reads the correlation id from its own state bundle in extractState, alongside every other request field, rather than from activity.getIntent(). This fragment supports being hosted by an activity it does not own, whose Intent would not carry the extra. The id is also added to onSaveInstanceState: the Intent survives activity recreation but the bundle did not carry it, so reading only from the bundle would otherwise have silently dropped the recorder after a config change AuthorizationActivity does not declare (e.g. uiMode). DiagnosticContext - UNSET_CORRELATION_ID is now public, and the internal duplicate string literal in getThreadCorrelationId() references it. Callers that use the correlation id as a KEY rather than for logging have to reject the sentinel explicitly, and a copied "UNSET" literal in the registry would silently stop matching if this ever changed - reintroducing cross-request contamination with no signal. Note this PR alone changes no behaviour: register/unregister have no caller in this repo (both live in the paired broker PR), so get() always returns null and the hooks stay inert. Merge order: #3197 -> #3201 -> #3204 -> broker PR. Tests: OnboardingRecorderRegistryTest (15) covers correlation-id keying, the UNSET sentinel (including the concrete two-request cross-wiring it prevents), removal / lifecycle, absent-key behaviour, null and empty inputs, idempotent unregister, and the leak bound. Revert-tested: removing the sentinel guard fails with "the sentinel must never become a key expected:<0> but was:<1>"; disabling the cap fails with "expected:<16> but was:<100>"; switching eviction to insertion order evicts a live in-use recorder. Verified end-to-end on device: built brokerHost against this change and drove a brokered MAM/Conditional-Access flow; the finalized blob carried the WebView- observed steps, last_loaded_domain, and blocking_errors ["530003"] from the Auth UX bridge. 186 tests green in this area: WebViewClient 98, bridge 41, recorder 18, registry 15, payload 10, correlation store 4.
shahzaibj:
- Sanitize the carried context fields (sessionID / pageId / trackingId / v) at
the sink seam. errorCode was shape-validated and correlationId was control-char
stripped, but these four crossed the same trust boundary raw, so a consumer
that logged one would inherit the log-forging hole already closed for
correlationId. Stripped and bounded; not shape-validated, since unlike
errorCode there is no contract shape to check. Null is preserved as null.
- Correct the span-attribute comment. It claimed the span "cannot report codes
that downstream telemetry never received", but CONSUMED explicitly covers a
sink that deliberately drops a code by policy - so a sink with an exclusion
list (as the onboarding sink has) legitimately produces a span carrying a code
the blob does not. The real guarantee is narrower: the span never carries a
code NO sink took. Documented as written rather than as wished.
- Document the sink contract for the not-ready window: signal with false, never
by throwing, because THREW suppresses retry for the rest of the page load and
would lose exactly the early-arrival events the false contract preserves.
Prvnkmr337:
- Rename AuthUxTelemetrySink.onAuthUxTelemetry -> tryConsumeAuthUxTelemetry. The
on... name reads as a notification, but the Boolean return decides whether the
event stays eligible for retry. No production caller names the method (the
sink is wired by method reference), so this is interface + tests only.
- Clarify the authux_js_error_code KDoc using the suggested wording, plus the
policy-drop caveat above so it is not read as a mirror of blocking_errors.
- Note that "v" is the wire name defined by the Auth UX telemetry contract.
- Add the missing span-attribute tests: set on CONSUMED, unset on NOT_CONSUMED
and on THREW, and last-consumed-code-wins across several codes.
The span tests use a small Java RecordingSpan extending the production NoopSpan,
so no OpenTelemetry SDK/exporter test dependency is added. It is Java because
Span.setAttribute(String, String) is a Java interface default method that
NoopSpan overrides, and Kotlin refuses to override it from a subclass or via
interface delegation ("overrides nothing").
Revert-tested: stamping the span unconditionally fails both "unset when the sink
declines" and "unset when the sink throws".
162 tests green: bridge 45 (was 41), payload 10, WebViewClient 87, recorder 16,
correlation store 4.
…ed), AB#3708195 The broker builds an onboarding telemetry recorder per request from the seed, but the WebView that renders the interactive auth / remediation pages lives in a separate AuthorizationActivity created by the OS from an Intent. A live recorder cannot ride an Intent, so the WebView-side onboarding hooks were inert for brokered flows: steps observed in the WebView (MDM enrollment, Company Portal launch, broker install), the last loaded domain, and the Auth UX log_telemetry error code never reached the blob the broker finalizes and returns. Both components run in the broker :auth process, so this adds an in-process handoff keyed by the request correlationId. OnboardingRecorderRegistry (new) - Rejects DiagnosticContext.UNSET_CORRELATION_ID as a key. Every thread whose request context was never set carries that sentinel, and the authorization Intent extra is populated by reading the request-context map directly rather than through getThreadCorrelationId(), so the raw sentinel can reach the registry. It is shared by definition: accepting it would let two unrelated requests resolve to the same recorder and merge one flow's blocking errors into the other's uploaded blob. Rejecting it makes the feature inert (and logs a warning) instead of silently mis-attributing telemetry, which for a component whose whole purpose is correct attribution is the only acceptable failure. - Bounded, access-ordered map (cap 16). Entries must be unregistered on terminal outcome, and because this is process-static state in a process that lives as long as the device is up, a missed unregister would otherwise leak a recorder permanently - the recorder object and its collected steps / blocking errors, not an Activity, since OnboardingTelemetryRecorder holds only the application context. On overflow it evicts the least-recently-used entry and logs a warning, bounding the leak while surfacing the underlying bug. - Stores the concrete OnboardingTelemetryRecorder because the WebView client's hooks (e.g. setLastLoadedDomain) use methods not on the common4j interface. WebViewAuthorizationFragment - Resolves the recorder in onCreateView and attaches it to the WebView client before initializeAuthUxJavaScriptApi(...). No-op when the request seeded no recorder, which is the MSAL-client path. - Reads the correlation id from its own state bundle in extractState, alongside every other request field, rather than from activity.getIntent(). This fragment supports being hosted by an activity it does not own, whose Intent would not carry the extra. The id is also added to onSaveInstanceState: the Intent survives activity recreation but the bundle did not carry it, so reading only from the bundle would otherwise have silently dropped the recorder after a config change AuthorizationActivity does not declare (e.g. uiMode). DiagnosticContext - UNSET_CORRELATION_ID is now public, and the internal duplicate string literal in getThreadCorrelationId() references it. Callers that use the correlation id as a KEY rather than for logging have to reject the sentinel explicitly, and a copied "UNSET" literal in the registry would silently stop matching if this ever changed - reintroducing cross-request contamination with no signal. Note this PR alone changes no behaviour: register/unregister have no caller in this repo (both live in the paired broker PR), so get() always returns null and the hooks stay inert. Merge order: #3197 -> #3201 -> #3204 -> broker PR. Tests: OnboardingRecorderRegistryTest (15) covers correlation-id keying, the UNSET sentinel (including the concrete two-request cross-wiring it prevents), removal / lifecycle, absent-key behaviour, null and empty inputs, idempotent unregister, and the leak bound. Revert-tested: removing the sentinel guard fails with "the sentinel must never become a key expected:<0> but was:<1>"; disabling the cap fails with "expected:<16> but was:<100>"; switching eviction to insertion order evicts a live in-use recorder. Verified end-to-end on device: built brokerHost against this change and drove a brokered MAM/Conditional-Access flow; the finalized blob carried the WebView- observed steps, last_loaded_domain, and blocking_errors ["530003"] from the Auth UX bridge. 186 tests green in this area: WebViewClient 98, bridge 41, recorder 18, registry 15, payload 10, correlation store 4.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (1)
common/src/test/java/com/microsoft/identity/common/internal/broker/AuthUxJavaScriptInterfaceTest.kt:236
- Use SpanExtension.makeCurrentSpan(span) instead of Span.makeCurrent() when installing the current span. SpanExtension wraps makeCurrent() to avoid known AbstractMethodError/NPE issues on some devices (and keeps test code aligned with the production pattern).
/** Runs [block] with [span] installed as the current span. */
private fun withCurrentSpan(span: Span, block: () -> Unit) {
span.makeCurrent().use { block() }
}
…ed), AB#3708195 The broker builds an onboarding telemetry recorder per request from the seed, but the WebView that renders the interactive auth / remediation pages lives in a separate AuthorizationActivity created by the OS from an Intent. A live recorder cannot ride an Intent, so the WebView-side onboarding hooks were inert for brokered flows: steps observed in the WebView (MDM enrollment, Company Portal launch, broker install), the last loaded domain, and the Auth UX log_telemetry error code never reached the blob the broker finalizes and returns. Both components run in the broker :auth process, so this adds an in-process handoff keyed by the request correlationId. OnboardingRecorderRegistry (new) - Rejects DiagnosticContext.UNSET_CORRELATION_ID as a key. Every thread whose request context was never set carries that sentinel, and the authorization Intent extra is populated by reading the request-context map directly rather than through getThreadCorrelationId(), so the raw sentinel can reach the registry. It is shared by definition: accepting it would let two unrelated requests resolve to the same recorder and merge one flow's blocking errors into the other's uploaded blob. Rejecting it makes the feature inert (and logs a warning) instead of silently mis-attributing telemetry. - Bounded, access-ordered map (cap 16). Entries must be unregistered on terminal outcome, and because this is process-static state in a process that lives as long as the device is up, a missed unregister would otherwise leak a recorder permanently - the recorder object and its collected steps / blocking errors, not an Activity, since OnboardingTelemetryRecorder holds only the application context. On overflow it evicts the least-recently-used entry and logs a warning, bounding the leak while surfacing the underlying bug. AuthorizationFragment - Captures the correlation id in extractState and round-trips it through onSaveInstanceState. The read was already a base-class responsibility, so the save belongs next to it: putting it in one subclass left the other two (BrowserAuthorizationFragment, CurrentTaskBrowserAuthorizationFragment) still blanking their diagnostic context after activity recreation, and invited the pair to drift apart again. All three subclasses already call super, so the parent implementation fixes all of them with no duplication. - This also repairs a pre-existing bug on that path: extractState previously fed setDiagnosticContextForNewThread(null) after a recreation, so every subsequent log line for the request lost its correlation id. WebViewAuthorizationFragment - Resolves the recorder in onCreateView from the inherited mCorrelationId and attaches it to the WebView client before initializeAuthUxJavaScriptApi(...). No-op when the request seeded no recorder, which is the MSAL-client path. DiagnosticContext - UNSET_CORRELATION_ID is now public, and the internal duplicate string literal in getThreadCorrelationId() references it. Callers that use the correlation id as a KEY rather than for logging have to reject the sentinel explicitly, and a copied "UNSET" literal in the registry would silently stop matching if this ever changed - reintroducing cross-request contamination with no signal. Logger - Notes that its own UNSET literal is a display placeholder, deliberately independent of the DiagnosticContext sentinel: it also stands in for a missing thread id and is never used as a key, so it must not be collapsed into the new constant. Note this PR alone changes no behaviour for the recorder handoff: register/unregister have no caller in this repo (both live in the paired broker PR). Merge order: #3197 -> #3201 -> #3204 -> broker PR. Tests - OnboardingRecorderRegistryTest (15): keying, the UNSET sentinel (including the concrete two-request cross-wiring it prevents), removal / lifecycle, absent-key behaviour, null and empty inputs, idempotent unregister, and the leak bound. - AuthorizationFragmentCorrelationIdTest (6, new): the fragment half of the handoff - extractState captures the id, the captured id resolves the registered recorder, it survives save/restore, and the no-recorder / missing-id / sentinel paths resolve to null rather than throwing. Without these, a regression in the correlation-id plumbing would leave every hook inert while the registry tests still passed. Revert-tested: removing the sentinel guard fails with "the sentinel must never become a key expected:<0> but was:<1>"; disabling the cap fails with "expected:<16> but was:<100>"; switching eviction to insertion order evicts a live in-use recorder; dropping the onSaveInstanceState round-trip fails with "the correlation id must round-trip through the saved bundle expected:<...> but was:<null>". Verified end-to-end on device: built brokerHost against this change and drove a brokered MAM/Conditional-Access flow; the finalized blob carried the WebView- observed steps, last_loaded_domain, and blocking_errors ["530003"] from the Auth UX bridge. 246 tests green across the onboarding / Auth UX / authorization-fragment suites.
Review feedback: sanitizeCarriedValue bounded sessionID / pageId / trackingId / v with MAX_CORRELATION_ID_LENGTH, a constant named for something else. Two problems with the reuse. The name does not describe the use, so a reader at the call site sees a correlation-ID bound applied to a pageId. And it coupled two unrelated limits: raising the correlation-ID bound (a cloud with longer IDs, say) would have silently moved the bound on values that have nothing to do with correlation, with nothing to catch it. Adds MAX_CARRIED_VALUE_LENGTH, deliberately equal at 128 today but independent. No behaviour change. 55 bridge tests green.
…ed), AB#3708195 The broker builds an onboarding telemetry recorder per request from the seed, but the WebView that renders the interactive auth / remediation pages lives in a separate AuthorizationActivity created by the OS from an Intent. A live recorder cannot ride an Intent, so the WebView-side onboarding hooks were inert for brokered flows: steps observed in the WebView (MDM enrollment, Company Portal launch, broker install), the last loaded domain, and the Auth UX log_telemetry error code never reached the blob the broker finalizes and returns. Both components run in the broker :auth process, so this adds an in-process handoff keyed by the request correlationId. OnboardingRecorderRegistry (new) - Rejects DiagnosticContext.UNSET_CORRELATION_ID as a key. Every thread whose request context was never set carries that sentinel, and the authorization Intent extra is populated by reading the request-context map directly rather than through getThreadCorrelationId(), so the raw sentinel can reach the registry. It is shared by definition: accepting it would let two unrelated requests resolve to the same recorder and merge one flow's blocking errors into the other's uploaded blob. Rejecting it makes the feature inert (and logs a warning) instead of silently mis-attributing telemetry. - Bounded, access-ordered map (cap 16). Entries must be unregistered on terminal outcome, and because this is process-static state in a process that lives as long as the device is up, a missed unregister would otherwise leak a recorder permanently - the recorder object and its collected steps / blocking errors, not an Activity, since OnboardingTelemetryRecorder holds only the application context. On overflow it evicts the least-recently-used entry and logs a warning, bounding the leak while surfacing the underlying bug. AuthorizationFragment - Captures the correlation id in extractState and round-trips it through onSaveInstanceState. The read was already a base-class responsibility, so the save belongs next to it: putting it in one subclass left the other two (BrowserAuthorizationFragment, CurrentTaskBrowserAuthorizationFragment) still blanking their diagnostic context after activity recreation, and invited the pair to drift apart again. All three subclasses already call super, so the parent implementation fixes all of them with no duplication. - This also repairs a pre-existing bug on that path: extractState previously fed setDiagnosticContextForNewThread(null) after a recreation, so every subsequent log line for the request lost its correlation id. WebViewAuthorizationFragment - Resolves the recorder in onCreateView from the inherited mCorrelationId and attaches it to the WebView client before initializeAuthUxJavaScriptApi(...). No-op when the request seeded no recorder, which is the MSAL-client path. DiagnosticContext - UNSET_CORRELATION_ID is now public, and the internal duplicate string literal in getThreadCorrelationId() references it. Callers that use the correlation id as a KEY rather than for logging have to reject the sentinel explicitly, and a copied "UNSET" literal in the registry would silently stop matching if this ever changed - reintroducing cross-request contamination with no signal. Logger - Notes that its own UNSET literal is a display placeholder, deliberately independent of the DiagnosticContext sentinel: it also stands in for a missing thread id and is never used as a key, so it must not be collapsed into the new constant. Note this PR alone changes no behaviour for the recorder handoff: register/unregister have no caller in this repo (both live in the paired broker PR). Merge order: #3197 -> #3201 -> #3204 -> broker PR. Tests - OnboardingRecorderRegistryTest (15): keying, the UNSET sentinel (including the concrete two-request cross-wiring it prevents), removal / lifecycle, absent-key behaviour, null and empty inputs, idempotent unregister, and the leak bound. - AuthorizationFragmentCorrelationIdTest (6, new): the fragment half of the handoff - extractState captures the id, the captured id resolves the registered recorder, it survives save/restore, and the no-recorder / missing-id / sentinel paths resolve to null rather than throwing. Without these, a regression in the correlation-id plumbing would leave every hook inert while the registry tests still passed. Revert-tested: removing the sentinel guard fails with "the sentinel must never become a key expected:<0> but was:<1>"; disabling the cap fails with "expected:<16> but was:<100>"; switching eviction to insertion order evicts a live in-use recorder; dropping the onSaveInstanceState round-trip fails with "the correlation id must round-trip through the saved bundle expected:<...> but was:<null>". Verified end-to-end on device: built brokerHost against this change and drove a brokered MAM/Conditional-Access flow; the finalized blob carried the WebView- observed steps, last_loaded_domain, and blocking_errors ["530003"] from the Auth UX bridge. 246 tests green across the onboarding / Auth UX / authorization-fragment suites.
…ed), AB#3708195 The broker builds an onboarding telemetry recorder per request from the seed, but the WebView that renders the interactive auth / remediation pages lives in a separate AuthorizationActivity created by the OS from an Intent. A live recorder cannot ride an Intent, so the WebView-side onboarding hooks were inert for brokered flows: steps observed in the WebView (MDM enrollment, Company Portal launch, broker install), the last loaded domain, and the Auth UX log_telemetry error code never reached the blob the broker finalizes and returns. Both components run in the broker :auth process, so this adds an in-process handoff keyed by the request correlationId. OnboardingRecorderRegistry (new) - Rejects DiagnosticContext.UNSET_CORRELATION_ID as a key. Every thread whose request context was never set carries that sentinel, and the authorization Intent extra is populated by reading the request-context map directly rather than through getThreadCorrelationId(), so the raw sentinel can reach the registry. It is shared by definition: accepting it would let two unrelated requests resolve to the same recorder and merge one flow's blocking errors into the other's uploaded blob. Rejecting it makes the feature inert (and logs a warning) instead of silently mis-attributing telemetry. - Bounded, access-ordered map (cap 16). Entries must be unregistered on terminal outcome, and because this is process-static state in a process that lives as long as the device is up, a missed unregister would otherwise leak a recorder permanently - the recorder object and its collected steps / blocking errors, not an Activity, since OnboardingTelemetryRecorder holds only the application context. On overflow it evicts the least-recently-used entry and logs a warning, bounding the leak while surfacing the underlying bug. AuthorizationFragment - Captures the correlation id in extractState and round-trips it through onSaveInstanceState. The read was already a base-class responsibility, so the save belongs next to it: putting it in one subclass left the other two (BrowserAuthorizationFragment, CurrentTaskBrowserAuthorizationFragment) still blanking their diagnostic context after activity recreation, and invited the pair to drift apart again. All three subclasses already call super, so the parent implementation fixes all of them with no duplication. - This also repairs a pre-existing bug on that path: extractState previously fed setDiagnosticContextForNewThread(null) after a recreation, so every subsequent log line for the request lost its correlation id. WebViewAuthorizationFragment - Resolves the recorder in onCreateView from the inherited mCorrelationId and attaches it to the WebView client before initializeAuthUxJavaScriptApi(...). No-op when the request seeded no recorder, which is the MSAL-client path. DiagnosticContext - UNSET_CORRELATION_ID is now public, and the internal duplicate string literal in getThreadCorrelationId() references it. Callers that use the correlation id as a KEY rather than for logging have to reject the sentinel explicitly, and a copied "UNSET" literal in the registry would silently stop matching if this ever changed - reintroducing cross-request contamination with no signal. Logger - Notes that its own UNSET literal is a display placeholder, deliberately independent of the DiagnosticContext sentinel: it also stands in for a missing thread id and is never used as a key, so it must not be collapsed into the new constant. Note this PR alone changes no behaviour for the recorder handoff: register/unregister have no caller in this repo (both live in the paired broker PR). Merge order: #3197 -> #3201 -> #3204 -> broker PR. Tests - OnboardingRecorderRegistryTest (15): keying, the UNSET sentinel (including the concrete two-request cross-wiring it prevents), removal / lifecycle, absent-key behaviour, null and empty inputs, idempotent unregister, and the leak bound. - AuthorizationFragmentCorrelationIdTest (6, new): the fragment half of the handoff - extractState captures the id, the captured id resolves the registered recorder, it survives save/restore, and the no-recorder / missing-id / sentinel paths resolve to null rather than throwing. Without these, a regression in the correlation-id plumbing would leave every hook inert while the registry tests still passed. Revert-tested: removing the sentinel guard fails with "the sentinel must never become a key expected:<0> but was:<1>"; disabling the cap fails with "expected:<16> but was:<100>"; switching eviction to insertion order evicts a live in-use recorder; dropping the onSaveInstanceState round-trip fails with "the correlation id must round-trip through the saved bundle expected:<...> but was:<null>". Verified end-to-end on device: built brokerHost against this change and drove a brokered MAM/Conditional-Access flow; the finalized blob carried the WebView- observed steps, last_loaded_domain, and blocking_errors ["530003"] from the Auth UX bridge. 246 tests green across the onboarding / Auth UX / authorization-fragment suites.
Two review findings, both about the sink being the trust boundary for the only
untrusted caller of addBlockingError.
Symbolic-code collision. The bridge shape-checks [A-Za-z0-9_-]{1,32}, which every
one of broker4j's symbolic blocking-error constants also matches -
DEVICE_REGISTRATION_NEEDED and INSUFFICIENT_DEVICE_REGISTRATION are the exact two
it writes on a real device-registration block. A page could post either, we would
append it to the same blocking_errors list and set last_blocking_error to it, and
nothing downstream - the onboarding dashboards included - could tell it from a
block the broker detected itself.
Fixed at the sink rather than at the bridge. The bridge is a generic seam and
cannot know where a code ends up; this sink knows its destination is a list shared
with broker4j, so the narrowing is its policy to make. Server-reported codes are
numeric, so ^[0-9]{1,32}$ closes the collision without rejecting anything a page
can legitimately report. (#3197 carries a matching doc note so the permissive
shape there is not read as a licence.)
Unbounded per-request growth. The bridge's caps (10 forwarded, 25 attempts) are
per INSTANCE and onPageStarted rebuilds it on every navigation, so they reset each
page load - this client's de-duplication set was the only thing spanning the
request, and nothing bounded it. A page cycling DISTINCT codes across navigations
could grow the uploaded blob without limit.
Capped at 10 distinct codes per request. Duplicates never counted anyway, and a
real flow reports one or two, so reaching this needs a page deliberately cycling
values. Checked AFTER the atomic add() rather than before, so the check-and-claim
stays one operation and the earlier check-then-act gap is not reintroduced; the
code is removed again when it does not fit. Returns consumed, so a capped code
does not burn the bridge's retry budget on something that can never be recorded.
The cap warning is logged once, on the transition, so it cannot become the flood
it exists to prevent.
Revert-tested: disabling either guard fails its test -
testRecordAuthUxServerErrorCode_SymbolicCodeIsRejected and
testRecordAuthUxServerErrorCode_PerRequestCapBoundsTheBlob.
179 tests green: WebViewClient 101, bridge 45, recorder 19, payload 10, store 4.
…ed), AB#3708195 The broker builds an onboarding telemetry recorder per request from the seed, but the WebView that renders the interactive auth / remediation pages lives in a separate AuthorizationActivity created by the OS from an Intent. A live recorder cannot ride an Intent, so the WebView-side onboarding hooks were inert for brokered flows: steps observed in the WebView (MDM enrollment, Company Portal launch, broker install), the last loaded domain, and the Auth UX log_telemetry error code never reached the blob the broker finalizes and returns. Both components run in the broker :auth process, so this adds an in-process handoff keyed by the request correlationId. OnboardingRecorderRegistry (new) - Rejects DiagnosticContext.UNSET_CORRELATION_ID as a key. Every thread whose request context was never set carries that sentinel, and the authorization Intent extra is populated by reading the request-context map directly rather than through getThreadCorrelationId(), so the raw sentinel can reach the registry. It is shared by definition: accepting it would let two unrelated requests resolve to the same recorder and merge one flow's blocking errors into the other's uploaded blob. Rejecting it makes the feature inert (and logs a warning) instead of silently mis-attributing telemetry. - Bounded, access-ordered map (cap 16). Entries must be unregistered on terminal outcome, and because this is process-static state in a process that lives as long as the device is up, a missed unregister would otherwise leak a recorder permanently - the recorder object and its collected steps / blocking errors, not an Activity, since OnboardingTelemetryRecorder holds only the application context. On overflow it evicts the least-recently-used entry and logs a warning, bounding the leak while surfacing the underlying bug. AuthorizationFragment - Captures the correlation id in extractState and round-trips it through onSaveInstanceState. The read was already a base-class responsibility, so the save belongs next to it: putting it in one subclass left the other two (BrowserAuthorizationFragment, CurrentTaskBrowserAuthorizationFragment) still blanking their diagnostic context after activity recreation, and invited the pair to drift apart again. All three subclasses already call super, so the parent implementation fixes all of them with no duplication. - This also repairs a pre-existing bug on that path: extractState previously fed setDiagnosticContextForNewThread(null) after a recreation, so every subsequent log line for the request lost its correlation id. WebViewAuthorizationFragment - Resolves the recorder in onCreateView from the inherited mCorrelationId and attaches it to the WebView client before initializeAuthUxJavaScriptApi(...). No-op when the request seeded no recorder, which is the MSAL-client path. DiagnosticContext - UNSET_CORRELATION_ID is now public, and the internal duplicate string literal in getThreadCorrelationId() references it. Callers that use the correlation id as a KEY rather than for logging have to reject the sentinel explicitly, and a copied "UNSET" literal in the registry would silently stop matching if this ever changed - reintroducing cross-request contamination with no signal. Logger - Notes that its own UNSET literal is a display placeholder, deliberately independent of the DiagnosticContext sentinel: it also stands in for a missing thread id and is never used as a key, so it must not be collapsed into the new constant. Note this PR alone changes no behaviour for the recorder handoff: register/unregister have no caller in this repo (both live in the paired broker PR). Merge order: #3197 -> #3201 -> #3204 -> broker PR. Tests - OnboardingRecorderRegistryTest (15): keying, the UNSET sentinel (including the concrete two-request cross-wiring it prevents), removal / lifecycle, absent-key behaviour, null and empty inputs, idempotent unregister, and the leak bound. - AuthorizationFragmentCorrelationIdTest (6, new): the fragment half of the handoff - extractState captures the id, the captured id resolves the registered recorder, it survives save/restore, and the no-recorder / missing-id / sentinel paths resolve to null rather than throwing. Without these, a regression in the correlation-id plumbing would leave every hook inert while the registry tests still passed. Revert-tested: removing the sentinel guard fails with "the sentinel must never become a key expected:<0> but was:<1>"; disabling the cap fails with "expected:<16> but was:<100>"; switching eviction to insertion order evicts a live in-use recorder; dropping the onSaveInstanceState round-trip fails with "the correlation id must round-trip through the saved bundle expected:<...> but was:<null>". Verified end-to-end on device: built brokerHost against this change and drove a brokered MAM/Conditional-Access flow; the finalized blob carried the WebView- observed steps, last_loaded_domain, and blocking_errors ["530003"] from the Auth UX bridge. 246 tests green across the onboarding / Auth UX / authorization-fragment suites.
3e0a810 to
cb19149
Compare
…3197) was squash-merged # Conflicts: # common/src/main/java/com/microsoft/identity/common/internal/broker/AuthUxJavaScriptInterface.kt # common/src/test/java/com/microsoft/identity/common/internal/broker/AuthUxJavaScriptInterfaceTest.kt
…erge) into the recorder-wiring branch
…#3197 squash-merge) into the telemetry-only bridge branch
Summary
Implements AB#3688631 (child of Feature AB#3688629 — Auth UX Telemetry Native Host: Broker / common-core (AndroidCommon)).
Adds a dedicated, non-mutating
log_telemetryaction to the existingAuthUxJavaScriptInterfaceJS bridge so Auth UX page JS can push an opaque server error code (e.g. an STS code such as530003) to the native host over the already-shippedwindow.broker.postMessageToBroker(...)→receiveAuthUxMessagepath — without creating a new bridge, WebView interface, or telemetry island.Wire format
Implemented against the Auth UX design-doc shape:
{ "correlationID": "$correlationId", "action_name": "log_telemetry", "action_component": "host", "params": { "v": 1, "sessionID": "$sessionID", "errorCode": 530003, "pageId": "ConvergedTFA", "trackingId": "$trackingId" } }Adding new key/value pairs — at the top level and inside
params— does not break parsing or dispatch: the custom deserializer reads only known top-level keys, and Gson ignores unknownparamsfields. Covered by explicit tests.Changes
Dispatch
action_name == "log_telemetry"first, beforeparams.operation. This ordering is load-bearing: alog_telemetrymessage that smugglesparams.operation = "number_matching"must never reach the number-match device store. Pinned by a test that fails if the branches are reordered.number_matchingcontinues to dispatch offparams.operation, unchanged.Sink seam
AuthUxTelemetrySinkreceives anAuthUxTelemetryEvent—correlationId,errorCode,sessionId,pageId,trackingId,version— rather than a bare error code.correlationIdis the Kusto / DRI join key; passing the whole context now avoids a source-breaking change to a published SAM interface when a downstream consumer wants those fields.falseleaves the code eligible for a later retry instead of it being suppressed as already-forwarded and lost.@JvmOverloads)telemetrySinkconstructor param, so the existing no-arg construction stays source- and binary-compatible.Validation and bounding —
errorCodeis page-controlled and (via AB#3688632) ends up in an uploaded blob, so it is:^[A-Za-z0-9_-]{1,32}$— permissive enough for both numeric STS codes and the symbolic constantsaddBlockingErroraccepts;action_name,operation,correlationID) is sanitized before logging too.Observability — distinct log lines for forwarded / malformed / duplicate / capped / not-consumed / no-sink-wired, each carrying the correlation ID; new
authux_js_error_codespan attribute, set only for codes a sink actually took (so the span cannot disagree with downstream telemetry). A throwing sink gets its owntry/catchand a sink-specific message rather than being misreported as a payload parsing failure, and a throw suppresses retry only — it does not set the span attribute or claim the code was forwarded, because nothing reached downstream telemetry.Model —
paramsis modelled with@SerializedName; all fields nullable. Notevis deliberately typedString?, notInt?: Gson throws on a type mismatch, so anInt?version meant a"1.0"or"v2"value would drop the entire message includingerrorCode. Please do not "clean this up" back toInt.Scope of the dedupe/cap
The cap is per bridge instance, and the WebView host rebuilds the bridge on every navigation — so it is a per-page flood guard, not a request-wide guarantee. Request-wide de-duplication belongs to the consumer that owns the blob and is implemented in AB#3688632. This is stated plainly in the KDoc and pinned by a test.
Security (H3 / H6)
The new branch is on a separate code path from
write_data/number_matching/storeNumberMatch— telemetry-only, append-oriented, and it never mutates device or credential state. The client takes no action on the code (H6);errorCodeis an opaque telemetry value.Threading
@JavascriptInterfacemethods are dispatched on the WebView's private JavaBridge thread, not the UI thread. The bridge serializes its own calls per instance and never invokes a sink while holding an internal lock. It cannot make host-owned state safe, so the recorder's collections are made thread-safe in AB#3688632 rather than relying on a documentation-only requirement.Merge order — please read
The host wiring for this seam lives in the stacked child PR #3201. If this PR lands on
devalone,devcarries alog_telemetrypath that no host wires: every message will parse, validate, and then log "no telemetry sink is wired; dropping". Land them together, or expect that silence.Both flows also remain gated on Auth UX actually emitting
log_telemetryserver-side (AB#3696811).Scope note — brokered flow only (by design)
Bridge exposure is gated by
shouldExposeJavaScriptInterface(...)→ProcessUtil.isRunningOnAuthService(...)(plus the origin allow-list and theENABLE_JS_API_FOR_AUTHUXflight), so this action is exercised in the brokered flow only. Non-brokered (OneAuth brokerless WebView) + iOS enablement is tracked under Feature AB#3688630.Out of scope (follow-up PBIs)
Testing
BUILD SUCCESSFUL —
AuthUxJavaScriptInterfaceTest37/37,AuthUxJsonPayloadTest10/10, 0 failures.Two behaviours are pinned by revert-tests (they fail if the fix is undone):
<[530003]>but was:<[]>", i.e. the code would otherwise be permanently lost;<25>but was:<40>", i.e. an unconsumed code would otherwise re-invoke the sink without limit.Known, accepted limitations
errorCode: trueis coerced by Gson to the string"true", which is alphanumeric and so indistinguishable from a symbolic code by shape. Validation bounds charset and length, not semantics — judging what is a meaningful error code is the server contract's job. Recorded as a named test rather than left undiscovered.v({major: 2}/[2]) still throws and drops the message. That shape is not part of the contract.errorCode: 530003.0is captured as"530003.0"and then rejected by validation — the recorded value depends on how the page serializes the number.