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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ vNext
- [MINOR] Add BrokerOAuth2TokenCache callerAuthorizedForFoci constructor gate: when false, shared FoCI cache reads are suppressed across both env-scoped and env==null chokepoints (getTokenCachesForClientId, getTokenCacheForClient), all loader/silent-flow fallbacks (load, loadWithAggregatedAccountData, saveAndLoadAggregatedAccountDataOptimized, loadAggregatedAccountData — the last returns a sparse singleton so downstream get(0) still resolves to UI-required), and device-wide enumerations (getAccounts(), getFociCacheRecords). Caller's own UID-partitioned accounts always returned. Exposes isCallerAuthorizedForFoci() and BrokerOAuth2TokenCacheTelemetryWrapper delegates the FoCI-touching APIs to the wrapped instance. The 3-arg convenience constructor (which silently defaulted the gate to true) is removed so every production callsite must supply the gate explicitly and fail-open regressions surface at compile time (#3187)
- [MINOR] Add a non-mutating log_telemetry action to the Auth UX JavaScript bridge that forwards a validated, page-reported server error code to an onboarding telemetry sink seam, kept strictly separate from the number-matching device-store path (#3197)
- [MINOR] Record the Auth UX log_telemetry server error code in the onboarding telemetry blob's blocking-errors list, and fix Auth UX bridge availability when the initial authorization URL is not allow-listed (#3201)
- [MINOR] Wire the broker's onboarding telemetry recorder into the :auth WebView host, and fix AuthorizationFragment losing the request's correlation id across recreation (affects all three authorization fragments) (#3204)

Version 24.5.0
----------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ public abstract class AuthorizationFragment extends Fragment {
*/
protected boolean mAuthResultSent = false;

/**
* Correlation id of this authorization request, read from the state bundle in
* {@link #extractState(Bundle)}.
*
* <p>Lives here rather than in a subclass because the read is a base-class responsibility: this
* class restores the diagnostic context from it, and {@link #onSaveInstanceState(Bundle)} below
* round-trips it so a recreated fragment restores that context instead of blanking it. Keeping
* the save next to the read means a new subclass cannot forget it.
*/
protected String mCorrelationId;

/**
* Whether the host opted in to MAM-CA install-referrer tagging for this request.
*/
Expand Down Expand Up @@ -171,13 +182,31 @@ user commit() rather than commitNow() that the fragment manager that we were rem
* @param state a bundle containing data provided when the activity was created
*/
void extractState(@NonNull final Bundle state) {
setDiagnosticContextForNewThread(state.getString(DiagnosticContext.CORRELATION_ID));
mCorrelationId = state.getString(DiagnosticContext.CORRELATION_ID);
setDiagnosticContextForNewThread(mCorrelationId);
mMamCaInstallReferrerEnabled = state.getBoolean(MAM_CA_INSTALL_REFERRER_ENABLED, false);
}

/**
* {@inheritDoc}
*
* <p>Round-trips the correlation id, which {@link #extractState(Bundle)} reads back on the
* recreation path. Without this the key is absent from the saved bundle and a recreated fragment
* blanks its diagnostic context, so every subsequent log line for the request loses its join
* key — and, for the WebView fragment, the onboarding recorder can no longer be resolved.
*
* <p>A null id is not written at all rather than stored as a null value. Storing it is harmless
* today — {@code RequestContext extends HashMap}, so the downstream {@code put} accepts null,
* and the only reader substitutes a random UUID — but that safety is load-bearing on the map
* type. Skipping the write keeps the absent case indistinguishable from "never saved", which is
* already handled, instead of relying on a null surviving every layer below.
*/
@Override
public void onSaveInstanceState(@NonNull final Bundle outState) {
super.onSaveInstanceState(outState);
if (mCorrelationId != null) {
outState.putString(DiagnosticContext.CORRELATION_ID, mCorrelationId);
}
outState.putBoolean(MAM_CA_INSTALL_REFERRER_ENABLED, mMamCaInstallReferrerEnabled);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@
import com.microsoft.identity.common.internal.ui.webview.WebViewUtil;
import com.microsoft.identity.common.internal.ui.webview.switchbrowser.SwitchBrowserStatusCallback;
import com.microsoft.identity.common.internal.ui.webview.switchbrowser.SwitchBrowserProtocolCoordinator;
import com.microsoft.identity.common.internal.telemetry.OnboardingRecorderRegistry;
import com.microsoft.identity.common.internal.telemetry.OnboardingTelemetryRecorder;
import com.microsoft.identity.common.java.logging.DiagnosticContext;
import com.microsoft.identity.common.java.WarningType;
import com.microsoft.identity.common.java.constants.FidoConstants;
import com.microsoft.identity.common.java.exception.ClientException;
Expand Down Expand Up @@ -313,6 +316,7 @@ void extractState(@NonNull final Bundle state) {
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
final String methodTag = TAG + ":onCreateView";
final View view = inflater.inflate(R.layout.common_activity_authentication, container, false);
mProgressBar = view.findViewById(R.id.common_auth_webview_progressbar);

Expand All @@ -322,6 +326,26 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c
}
mAADWebViewClient = createAADWebViewClient(activity);
setUpWebView(view, mAADWebViewClient);

// Onboarding telemetry (brokered): if AccountChooser seeded a recorder for this request,
// hand it to the WebView client so WebView-observed onboarding steps (MDM enrollment,
// Company Portal launch, broker install) and the Auth UX log_telemetry error code are
// recorded into the same blob the broker finalizes and returns. Keyed by correlationId via
// OnboardingRecorderRegistry (owner + WebView both run in the broker :auth process). No-op
// when the request seeded no recorder, or when the correlation id is unusable as a key.
// Must stay ahead of initializeAuthUxJavaScriptApi and launchWebView below, so the client
// already holds the recorder before the first page can reach the bridge. AB#3708195.
final OnboardingTelemetryRecorder onboardingRecorder =
OnboardingRecorderRegistry.get(mCorrelationId);
if (onboardingRecorder != null) {
Logger.info(methodTag, mCorrelationId,
"Onboarding telemetry: attaching recorder to WebView client");
mAADWebViewClient.setOnboardingTelemetryRecorder(onboardingRecorder);
} else {
Logger.verbose(methodTag, mCorrelationId,
"Onboarding telemetry: no recorder registered for this request");
}

mAADWebViewClient.initializeAuthUxJavaScriptApi(mWebView, mAuthorizationRequestUrl);
launchWebView(mAuthorizationRequestUrl, mRequestHeaders);
return view;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
// Copyright (c) Microsoft Corporation.
// All rights reserved.
//
// This code is licensed under the MIT License.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files(the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions :
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
package com.microsoft.identity.common.internal.telemetry

import androidx.annotation.GuardedBy
import androidx.annotation.VisibleForTesting
import com.microsoft.identity.common.java.logging.DiagnosticContext
import com.microsoft.identity.common.logging.Logger

/**
* In-process handoff for the onboarding telemetry recorder between the component that OWNS it and
* the interactive WebView host that CONSUMES it.
*
* On the broker side the recorder is built per-request from the onboarding seed by
* `AccountChooser` (main broker logic), while the WebView that renders the interactive auth /
* remediation pages lives in a separate `AuthorizationActivity` created by the OS from an Intent.
* A live recorder instance cannot ride an Intent, but both components run in the broker `:auth`
* process, so a process-static registry keyed by the request **correlationId** bridges them:
*
* - `AccountChooser` [register]s its recorder when it seeds one.
* - [com.microsoft.identity.common.internal.providers.oauth2.WebViewAuthorizationFragment] looks it
* up by correlationId and hands it to
* [com.microsoft.identity.common.internal.ui.webview.AzureActiveDirectoryWebViewClient.setOnboardingTelemetryRecorder]
* so WebView-observed onboarding steps (MDM enrollment, Company Portal launch, broker install)
* and the Auth UX `log_telemetry` error code are recorded into the same blob that is finalized
* and returned in the broker result.
*
* Entries MUST be [unregister]ed on terminal outcome / teardown to avoid leaking recorders across
* requests. Stores the concrete [OnboardingTelemetryRecorder] because the WebView client's
* telemetry hooks (e.g. `setLastLoadedDomain`) use methods that are not on the common4j interface.
*
* **The correlation id must be a real request id.** [DiagnosticContext.UNSET_CORRELATION_ID] is the
* value every thread carries before its request context is set, and the authorization Intent extra
* is populated by reading the request context map directly rather than through
* `getThreadCorrelationId()` — so the raw sentinel can reach this class. It is shared by definition,
* so accepting it as a key would let two unrelated requests resolve to the same recorder and merge
* one flow's blocking errors into the other's uploaded blob. All three accessors reject it: the
* feature goes inert (and [register] warns) instead of silently mis-attributing telemetry, which for
* a component whose entire purpose is correct attribution is the only acceptable failure.
*
* Because this is process-static state in the long-lived broker `:auth` process, a missed
* [unregister] would leak a recorder permanently — the recorder object and its collected steps /
* blocking errors, not an Activity, since [OnboardingTelemetryRecorder] deliberately holds only the
* application context. The map is therefore capped at [MAX_ENTRIES]: once full, registering evicts
* the least-recently-used entry and logs a warning. That converts an unbounded leak into a bounded
* one and makes the underlying bug visible, rather than hiding it. The cap is far above real
* concurrency (the broker drives one interactive request at a time), so eviction should not happen
* at all; if the warning appears, it is the signal that a terminal path is failing to unregister.
*
* Eviction is least-recently-*used* rather than oldest-registered. In today's flow that is not
* load-bearing — the WebView host calls [get] exactly once per request and holds the reference
* thereafter, so evicting a live entry would not disturb the in-flight request anyway — but it is
* free, and it is the safer default if a future caller ever re-resolves mid-request.
*/
object OnboardingRecorderRegistry {

private val TAG = OnboardingRecorderRegistry::class.java.simpleName

/**
* Upper bound on concurrently-registered recorders. Generous relative to real concurrency; it
* exists to bound a leak from a missed [unregister], not to constrain legitimate use.
*/
private const val MAX_ENTRIES = 16

// Access-ordered LinkedHashMap so eviction drops the least-recently-touched entry rather than
// simply the oldest-registered one. Guarded by its own monitor: LinkedHashMap is not
// thread-safe, and get() mutates access order, so reads need the lock too.
private val recorders = object : LinkedHashMap<String, OnboardingTelemetryRecorder>(
/* initialCapacity = */ MAX_ENTRIES,
/* loadFactor = */ 0.75f,
/* accessOrder = */ true
) {
override fun removeEldestEntry(
eldest: MutableMap.MutableEntry<String, OnboardingTelemetryRecorder>?
): Boolean {
if (size <= MAX_ENTRIES) {
return false
}
// Recorded rather than logged here: this runs inside put(), which runs under the
// registry lock, and every other log on this class is emitted outside it.
evictedKey = eldest?.key
return true
}
}

// Written by removeEldestEntry, which runs inside put() and therefore already under the registry
// lock, then read and cleared by register() in that same critical section. Only the resulting
// warning is logged after the lock is released, so nothing on this class logs on-lock.
@GuardedBy("recorders")
private var evictedKey: String? = null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: can we put @GuardedBy("recorders") on evictedKey? It's a plain var that's only safe because removeEldestEntry writes it and register reads/clears it under the same lock. The comment already spells that out, the annotation just makes it harder for a future edit to touch it off-lock without noticing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done, in 01a2b2d25.

Worth flagging that the comment it points at was wrong, in a way that mattered for exactly this annotation. It said evictedKey is "read and cleared by register() immediately after the lock is released" — but the read-and-clear is evictedKey.also { evictedKey = null } inside the synchronized(recorders) block; only the resulting warning is logged off-lock. So the prose described a lock discipline that would have made @GuardedBy("recorders") a lie, and a future reader trusting it would have concluded the annotation was wrong and removed it rather than the access. Reworded so the two now agree.

Both accesses verified under the monitor: removeEldestEntry runs inside put(), which the caller already holds the lock for, and register()'s read/clear is in the same critical section.

Used androidx.annotation.GuardedBy — same artifact as the VisibleForTesting already imported here, so no new dependency, and it's the variant Android Lint's GuardedBy check understands.


/**
* Returns [correlationId] when it can safely key an entry — present, and not
* [DiagnosticContext.UNSET_CORRELATION_ID] — or null when it cannot.
*
* The sentinel is the value carried by any thread whose request context was never set, so it is
* shared rather than unique. Keying on it would let unrelated requests resolve to the same
* recorder — see this class's KDoc.
*
* Returns the key rather than a Boolean so callers get a non-null String to index the map with.
*/
private fun usableKeyOrNull(correlationId: String?): String? =
if (!correlationId.isNullOrEmpty() &&
correlationId != DiagnosticContext.UNSET_CORRELATION_ID
) {
correlationId
} else {
null
}

/**
* Register [recorder] for [correlationId]. No-op when the recorder is null or the correlation id
* is not usable as a key (null, empty, or the unset sentinel), which is logged because a seeded
* recorder that cannot be handed off is a real defect.
*/
@JvmStatic
fun register(correlationId: String?, recorder: OnboardingTelemetryRecorder?) {
if (recorder == null) {
return
}
val key = usableKeyOrNull(correlationId)
if (key == null) {
Logger.warn(
TAG, correlationId,
"Not registering the onboarding recorder: the correlation id is missing or is the " +
"unset sentinel, so it cannot identify this request. Onboarding telemetry will " +
"be inert for it."
)
return
}
val evicted = synchronized(recorders) {
recorders[key] = recorder
evictedKey.also { evictedKey = null }
}
Logger.info(TAG, key, "Registered onboarding recorder")
if (evicted != null) {
Logger.warn(
TAG, evicted,
"Onboarding recorder registry was full ($MAX_ENTRIES); evicted the " +
"least-recently-used entry. A terminal path is likely failing to unregister."
)
}
}

/**
* Return the recorder registered for [correlationId], or null when none is registered (e.g. the
* request carried no onboarding seed) or the correlation id is not usable as a key.
*
* Deliberately silent: the no-seed path calls this on every interactive request.
*/
@JvmStatic
fun get(correlationId: String?): OnboardingTelemetryRecorder? {
val key = usableKeyOrNull(correlationId) ?: return null
return synchronized(recorders) { recorders[key] }
}

/**
* Remove the recorder registered for [correlationId]. Safe to call when none is registered or
* the correlation id is not usable as a key.
*/
@JvmStatic
fun unregister(correlationId: String?) {
val key = usableKeyOrNull(correlationId) ?: return
val removed = synchronized(recorders) { recorders.remove(key) }
if (removed != null) {
Logger.info(TAG, key, "Unregistered onboarding recorder")
}
}

/** Number of currently-registered recorders. Test-only. */
@JvmStatic
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun size(): Int = synchronized(recorders) { recorders.size }

/** Drop all entries so one test cannot observe another's registrations. Test-only. */
@JvmStatic
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun clearForTest() {
synchronized(recorders) { recorders.clear() }
}
}
Loading
Loading