diff --git a/changelog.txt b/changelog.txt index 0a22df9bfd..6e16007211 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,9 +1,12 @@ vNext ---------- -Version 24.2.1-RC1 ----------- - +Version 24.2.1-RC1 +---------- +- [MINOR] Add additional step ID and blocking error constants for full onboarding telemetry coverage (#3117) +- [MINOR] Add onboarding telemetry blob fields to BrokerRequest/BrokerResult and command parameters for client↔broker IPC transport (#3111) +- [MINOR] Add onboarding telemetry recorder, field keys, and session persistence for mobile onboarding flow (#3088) + Version 24.2.0 ---------- - [PATCH] Add support for Authenticator app activation links in WebView, enabling account pairing/MFA flows to launch Microsoft Authenticator directly instead of redirecting to the Play Store (#3090) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/broker/BrokerRequest.java b/common/src/main/java/com/microsoft/identity/common/internal/broker/BrokerRequest.java index 8f288f6684..991e6fb365 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/broker/BrokerRequest.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/broker/BrokerRequest.java @@ -87,6 +87,7 @@ private static final class SerializedNames { final static String TENANT_ID = "tenant_id"; final static String REQUEST_TYPE = "request_type"; final static String WEB_APPS_STATE = "web_apps_state"; + final static String ONBOARDING_SEED_JSON = "onboarding_seed_json"; } /** @@ -295,4 +296,17 @@ private static final class SerializedNames { @Nullable @SerializedName(SerializedNames.REQUEST_TYPE) private String mRequestType; + + /** + * Onboarding telemetry seed JSON blob. + * Direction: client → broker (input only). Contains sessionCorrelationId, + * onboarding_mode, and schema_version, supplied by the client (OneAuth/MSAL) + * so the broker can construct an OnboardingTelemetryRecorder using the same + * correlation id. The broker returns the populated blob (with steps and + * blocking errors) via {@link BrokerResult#getOnboardingBlob()}, not via + * this field. + */ + @Nullable + @SerializedName(SerializedNames.ONBOARDING_SEED_JSON) + private String mOnboardingSeedJson; } diff --git a/common/src/main/java/com/microsoft/identity/common/internal/broker/BrokerResult.java b/common/src/main/java/com/microsoft/identity/common/internal/broker/BrokerResult.java index 4c0cc5f3c2..a168acefbf 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/broker/BrokerResult.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/broker/BrokerResult.java @@ -97,6 +97,7 @@ private static class SerializedNames { static final String SPE_RING = "spe_ring"; static final String CLI_TELEM_ERRORCODE = "cli_telem_error_code"; static final String CLI_TELEM_SUB_ERROR_CODE = "cli_telem_suberror_code"; + static final String ONBOARDING_BLOB = "onboarding_blob"; } private static final long serialVersionUID = 8606631820514878489L; @@ -327,6 +328,13 @@ private static class SerializedNames { @SerializedName(SerializedNames.BROKER_AAD_DEVICE_ID_RECORD) private final AadDeviceIdRecord mAadDeviceIdRecord; + /** + * Populated onboarding telemetry blob JSON returned by the broker. + */ + @Nullable + @SerializedName(SerializedNames.ONBOARDING_BLOB) + private final String mOnboardingBlob; + private BrokerResult(@NonNull final Builder builder) { mAccessToken = builder.mAccessToken; mIdToken = builder.mIdToken; @@ -362,6 +370,7 @@ private BrokerResult(@NonNull final Builder builder) { mCliTelemSubErrorCode = builder.mCliTelemSubErrorCode; mExceptionType = builder.mExceptionType; mAadDeviceIdRecord = builder.mAadDeviceIdRecord; + mOnboardingBlob = builder.mOnboardingBlob; } public String getExceptionType() { @@ -498,6 +507,11 @@ public AadDeviceIdRecord getAadDeviceIdRecord() { return mAadDeviceIdRecord; } + @Nullable + public String getOnboardingBlob() { + return mOnboardingBlob; + } + public static class Builder { private String mAccessToken; private String mIdToken; @@ -523,6 +537,7 @@ public static class Builder { private List mTenantProfileData; private boolean mServicedFromCache; private AadDeviceIdRecord mAadDeviceIdRecord; + private String mOnboardingBlob; // Exception parameters private String mErrorCode; @@ -535,7 +550,6 @@ public static class Builder { private String mCliTelemErrorCode; private String mCliTelemSubErrorCode; private String mExceptionType; - public Builder accessToken(@Nullable final String accessToken) { this.mAccessToken = accessToken; return this; @@ -712,6 +726,11 @@ public Builder exceptionType(String exceptionType) { this.mExceptionType = exceptionType; return this; } + + public Builder onboardingBlob(@Nullable final String onboardingBlob) { + this.mOnboardingBlob = onboardingBlob; + return this; + } } } diff --git a/common/src/main/java/com/microsoft/identity/common/internal/request/MsalBrokerRequestAdapter.java b/common/src/main/java/com/microsoft/identity/common/internal/request/MsalBrokerRequestAdapter.java index 3d7c4f568c..109b6190ec 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/request/MsalBrokerRequestAdapter.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/request/MsalBrokerRequestAdapter.java @@ -139,7 +139,8 @@ public BrokerRequest brokerRequestFromAcquireTokenParameters(@NonNull final Inte .preferredBrowser(parameters.getPreferredBrowser()) .preferredAuthMethod(parameters.getPreferredAuthMethod()) .accountTransferToken(parameters.getAccountTransferToken()) - .suppressAccountPicker(parameters.isSuppressBrokerAccountPicker()); + .suppressAccountPicker(parameters.isSuppressBrokerAccountPicker()) + .onboardingSeedJson(parameters.getOnboardingSeedJson()); if (parameters instanceof AndroidInteractiveTokenCommandParameters) { final AndroidInteractiveTokenCommandParameters androidInteractiveTokenCommandParameters = (AndroidInteractiveTokenCommandParameters) parameters; diff --git a/common/src/main/java/com/microsoft/identity/common/internal/result/MsalBrokerResultAdapter.java b/common/src/main/java/com/microsoft/identity/common/internal/result/MsalBrokerResultAdapter.java index d9b31430c9..1c40c2c646 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/result/MsalBrokerResultAdapter.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/result/MsalBrokerResultAdapter.java @@ -465,9 +465,16 @@ public Bundle bundleFromBaseExceptionForWebApps(@NonNull final BaseException exc @NonNull @Override public ILocalAuthenticationResult authenticationResultFromBundle(@NonNull final Bundle resultBundle) throws ClientException { - final String methodTag = TAG + ":authenticationResultFromBundle"; - final BrokerResult brokerResult = brokerResultFromBundle(resultBundle); + return authenticationResultFromBrokerResult(brokerResultFromBundle(resultBundle)); + } + /** + * Overload that builds the authentication result from an already-deserialized + * {@link BrokerResult}. Use this when the caller has the [BrokerResult] in hand + * to avoid a redundant deserialization of the result bundle. + */ + public ILocalAuthenticationResult authenticationResultFromBrokerResult(@NonNull final BrokerResult brokerResult) throws ClientException { + final String methodTag = TAG + ":authenticationResultFromBrokerResult"; Logger.info(methodTag, "Broker Result returned from Bundle, constructing authentication result"); final List tenantProfileCacheRecords = brokerResult.getTenantProfileData(); @@ -565,6 +572,39 @@ public BrokerPerformanceMetrics getBrokerPerformanceMetricsFromBundle(@NonNull f } } + /** + * Extracts the onboarding telemetry blob (JSON string) from the result bundle. + * Best-effort: returns null if the bundle cannot be deserialized into a BrokerResult + * or if no blob is present. Telemetry failures must never fail an otherwise-successful + * auth result. Blob contents are not logged (may carry sessionCorrelationId). + * + * If the caller has already deserialized the [BrokerResult], prefer the overload + * [getOnboardingBlobFromBundle(BrokerResult)] to avoid a second deserialization. + */ + @Nullable + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + public String getOnboardingBlobFromBundle(@NonNull final Bundle resultBundle) { + final String methodTag = TAG + ":getOnboardingBlobFromBundle"; + try { + final BrokerResult brokerResult = brokerResultFromBundle(resultBundle); + return brokerResult.getOnboardingBlob(); + } catch (final ClientException e) { + Logger.warn(methodTag, "Failed to extract onboarding blob from broker result: " + e.getErrorCode()); + return null; + } + } + + /** + * Overload that reads the onboarding telemetry blob from a {@link BrokerResult} + * that has already been deserialized by the caller. Use this when you already + * have a [BrokerResult] in hand to avoid a second deserialization of the bundle. + */ + @Nullable + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + public String getOnboardingBlobFromBundle(@NonNull final BrokerResult brokerResult) { + return brokerResult.getOnboardingBlob(); + } + @NonNull @Override public AcquirePrtSsoTokenResult getAcquirePrtSsoTokenResultFromBundle(Bundle resultBundle) { @@ -1017,9 +1057,14 @@ public AcquireTokenResult getDeviceCodeFlowTokenResultFromResultBundle(@NonNull AcquireTokenResult getAcquireTokenResultFromResultBundle(@NonNull final Bundle resultBundle) throws BaseException { final MsalBrokerResultAdapter resultAdapter = new MsalBrokerResultAdapter(); if (resultBundle.getBoolean(AuthenticationConstants.Broker.BROKER_REQUEST_V2_SUCCESS)) { + // Deserialize BrokerResult once and reuse for both the local authentication result + // and the onboarding telemetry blob, instead of letting authenticationResultFromBundle + // and getOnboardingBlobFromBundle each pay the deserialization cost. + final BrokerResult brokerResult = resultAdapter.brokerResultFromBundle(resultBundle); + final AcquireTokenResult acquireTokenResult = new AcquireTokenResult(); acquireTokenResult.setLocalAuthenticationResult( - resultAdapter.authenticationResultFromBundle(resultBundle) + resultAdapter.authenticationResultFromBrokerResult(brokerResult) ); // Set broker performance metrics if available final BrokerPerformanceMetrics metrics = resultAdapter.getBrokerPerformanceMetricsFromBundle(resultBundle); @@ -1038,6 +1083,13 @@ AcquireTokenResult getAcquireTokenResultFromResultBundle(@NonNull final Bundle r resultBundle.getString(AuthenticationConstants.Broker.BROKER_PACKAGE_NAME) ); } + + // Set onboarding telemetry blob if present (best-effort; never fails the result). + final String onboardingBlob = resultAdapter.getOnboardingBlobFromBundle(brokerResult); + if (onboardingBlob != null) { + acquireTokenResult.setOnboardingBlob(onboardingBlob); + } + return acquireTokenResult; } diff --git a/common/src/main/java/com/microsoft/identity/common/internal/telemetry/OnboardingSessionCorrelationStore.kt b/common/src/main/java/com/microsoft/identity/common/internal/telemetry/OnboardingSessionCorrelationStore.kt new file mode 100644 index 0000000000..7ee06b9087 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/internal/telemetry/OnboardingSessionCorrelationStore.kt @@ -0,0 +1,59 @@ +// 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 android.content.Context + +/** + * SharedPreferences-backed persistence for session correlation IDs. + * Used by OneAuth (via JNI/Djinni SessionCachePersistence adapter). + * Each app (OneAuth host, broker) has its own sandboxed SharedPreferences file; + * the same schema and file name are used across apps for consistency. + * OnboardingTelemetryRecorder also writes to this file on block detection. + */ +class OnboardingSessionCorrelationStore(context: Context) { + + private val appContext: Context = context.applicationContext + + /** + * Load the persisted session correlation cache JSON string. + * @return JSON string, or empty string if nothing is persisted + */ + fun load(): String { + val prefs = appContext.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE) + return prefs.getString(PREFS_FILE, "") ?: "" + } + + /** + * Save the session correlation cache JSON string to SharedPreferences. + * @param json The JSON string to persist + */ + fun save(json: String) { + val prefs = appContext.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE) + prefs.edit().putString(PREFS_FILE, json).apply() + } + + companion object { + private const val PREFS_FILE = "com.microsoft.oneauth.session_correlation_cache" + } +} diff --git a/common/src/main/java/com/microsoft/identity/common/internal/telemetry/OnboardingTelemetryRecorder.kt b/common/src/main/java/com/microsoft/identity/common/internal/telemetry/OnboardingTelemetryRecorder.kt new file mode 100644 index 0000000000..4a2733ad0a --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/internal/telemetry/OnboardingTelemetryRecorder.kt @@ -0,0 +1,313 @@ +// 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 android.content.Context +import com.microsoft.identity.common.java.telemetry.OnboardingTelemetryConstants +import com.microsoft.identity.common.logging.Logger +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Records onboarding telemetry events during interactive auth flows. + * Called by WebView navigation fragments to track steps, blocking errors, + * and domain navigation. Operates on an in-memory model, converts + * to JSON at flow end. On block detection, persists sessionCorrelationId + * to SharedPreferences for app-kill resilience (best-effort, async). + * + * Lives in Android common core so both OneAuth (non-brokered) and broker apps + * (brokered) use the same class. + * + * @param seedJson Seed blob JSON produced by the C++ xplat core (via Djinni + * `OnboardingBlobConstants`) and passed in from `authParameters`. + * Expected shape: + * `{ "schema_version": "1.0.0", + * "session_correlation_id": "", + * "onboarding_mode": "brokered" | "non-brokered" }`. + * If null/blank/malformed, the recorder still functions but with empty + * seed fields; a warning is logged and `finalizeBlob()` will refuse to + * emit a blob with an empty `sessionCorrelationId`. + * @param clientId Client (application) ID, used as part of the SharedPreferences + * cache key for session correlation persistence. + * @param target Target scopes (space-joined, sorted) for the same cache key. + * @param context Any Android context; the application context is captured internally + * so the recorder can outlive the originating Activity/Fragment without + * leaking it. + */ +class OnboardingTelemetryRecorder( + seedJson: String, + private val clientId: String, + private val target: String, // sorted, space-joined scopes + context: Context +) { + + // Use applicationContext so this recorder, which may outlive the originating + // Activity/Fragment, never holds a reference that would leak that context. + private val appContext: Context = context.applicationContext + + // Seed fields (from C++ common core) + private val schemaVersion: String + val sessionCorrelationId: String + private val onboardingMode: String + + // Populated fields + private val stepsList: MutableList = mutableListOf() + private val blockingErrors: MutableList = mutableListOf() + private var lastLoadedDomain: String? = null + private var profile: String? = null + private val uxFlowUsed: MutableList = mutableListOf() + + init { + val parsed = parseSeed(seedJson) + schemaVersion = parsed?.first ?: "" + sessionCorrelationId = parsed?.second ?: "" + onboardingMode = parsed?.third ?: "" + } + + /** + * Parse the seed JSON into [schemaVersion], [sessionCorrelationId], and [onboardingMode]. + * Returns null if the seed is null/blank or fails to parse — callers fall back to + * empty-string defaults rather than receiving a fake-empty Triple. + */ + private fun parseSeed(json: String): Triple? { + if (json.isBlank()) return null + return try { + val seed = JSONObject(json) + Triple( + seed.optString(FIELD_SCHEMA_VERSION, ""), + seed.optString(FIELD_SESSION_CORRELATION_ID, ""), + seed.optString(FIELD_ONBOARDING_MODE, "") + ) + } catch (e: JSONException) { + Logger.warn( + TAG, + "Failed to parse onboarding seed JSON; recorder will operate with empty fields: " + e.message + ) + null + } + } + + private data class StepEntry(val stepId: String, val timestamp: String) + + /** + * Record a step in the onboarding flow. Captures the current time automatically. + * + * @param stepId Step ID constant (from OnboardingTelemetryConstants) + */ + fun addStep(stepId: String) { + val isoTimestamp = SimpleDateFormat(ISO_TIMESTAMP_FORMAT, Locale.US).format(Date()) + stepsList.add(StepEntry(stepId, isoTimestamp)) + } + + /** + * Record a blocking error detected during the flow. + * Also persists the session correlation entry to SharedPreferences + * (best-effort, async) for app-kill resilience. + * + * @param errorCode The onboarding blocking-error identifier to record + * (e.g., [OnboardingTelemetryConstants.BLOCKING_ERROR_BROKER_INSTALL] + * or [OnboardingTelemetryConstants.BLOCKING_ERROR_MDM_FLOW]), + * not a numeric service auth error code. + */ + fun addBlockingError(errorCode: String) { + blockingErrors.add(errorCode) + + // Persist session correlation to SharedPreferences immediately on block + persistSessionCorrelation() + } + + /** + * Set the last loaded domain during WebView navigation. + * + * @param domain The domain URL (e.g., "login.microsoftonline.com") + */ + fun setLastLoadedDomain(domain: String) { + lastLoadedDomain = domain + } + + /** + * Set the Android profile context. + * + * @param profile One of [OnboardingTelemetryConstants.PROFILE_USER] or + * [OnboardingTelemetryConstants.PROFILE_WORK] + */ + fun setProfile(profile: String) { + this.profile = profile + } + + /** + * Add a UX flow variant tag to the onboarding blob. + * + * The tag identifies which experiment/feature variant the user was exposed to during + * the onboarding journey (e.g. a phased rollout cohort like `"MobileOnboardingPhase1"`, + * or a remediation experiment like `"MdmEnrollmentRedesign_v2"`). Multiple tags can be + * added when several flights apply to the same flow. The values are emitted as the + * `ux_flow_used` array in the populated blob and surface in MATS as `mo_ux_flow_used`, + * enabling per-experiment slicing of the onboarding funnel. + * + * @param flowTag Caller-defined experiment/variant identifier. + */ + fun addUxFlowUsed(flowTag: String) { + uxFlowUsed.add(flowTag) + } + + /** + * Finalize the blob and return the JSON string. + * If no blocking errors were recorded, returns empty string (clears seed blob). + * Otherwise serializes the populated blob to JSON. + * + * @return Populated blob JSON string, or empty string if no blocking errors + */ + fun finalizeBlob(): String { + if (blockingErrors.isEmpty()) { + Logger.verbose(TAG, sessionCorrelationId, "finalizeBlob: no blocking errors recorded, returning empty") + return EMPTY_BLOB + } + if (sessionCorrelationId.isEmpty()) { + Logger.warn( + TAG, + "finalizeBlob: sessionCorrelationId is empty; returning empty blob (skipping MATS emission) " + + "to avoid emitting telemetry that cannot be joined with the broker side or with retries" + ) + return EMPTY_BLOB + } + + return try { + val blob = JSONObject().apply { + // Seed fields + put(FIELD_SCHEMA_VERSION, schemaVersion) + put(FIELD_SESSION_CORRELATION_ID, sessionCorrelationId) + put(FIELD_ONBOARDING_MODE, onboardingMode) + + // StepsList + val steps = JSONArray() + for (entry in stepsList) { + steps.put(JSONObject().apply { + put(FIELD_STEP_ID, entry.stepId) + put(FIELD_TS, entry.timestamp) + }) + } + put(FIELD_STEPS_LIST, steps) + + // Platform builder fields + val errorsArray = JSONArray() + for (error in blockingErrors) { + errorsArray.put(error) + } + put(OnboardingTelemetryConstants.BLOCKING_ERRORS, errorsArray) + put( + OnboardingTelemetryConstants.LAST_BLOCKING_ERROR, + blockingErrors.last() + ) + + lastLoadedDomain?.let { + put(OnboardingTelemetryConstants.LAST_LOADED_DOMAIN, it) + } + + if (stepsList.isNotEmpty()) { + put( + OnboardingTelemetryConstants.LAST_COMPLETED_STEP, + stepsList.last().stepId + ) + } + + profile?.let { + put(OnboardingTelemetryConstants.PROFILE, it) + } + + if (uxFlowUsed.isNotEmpty()) { + val flows = JSONArray() + for (flow in uxFlowUsed) { + flows.put(flow) + } + put(OnboardingTelemetryConstants.UX_FLOW_USED, flows) + } + } + + blob.toString() + } catch (e: JSONException) { + Logger.error(TAG, sessionCorrelationId, "Failed to serialize onboarding blob", e) + EMPTY_BLOB + } + } + + /** + * Persist session correlation entry to SharedPreferences. + * Uses async [SharedPreferences.Editor.apply] — the in-memory write is + * effective immediately, and the disk flush happens shortly after. Acceptable + * for this use case: blocking errors leave the app alive for seconds-to-minutes + * of user remediation, so the flush window is far longer than typical loss. + * Telemetry tolerates rare loss; we avoid main-thread disk I/O. + * Called on block detection. + */ + private fun persistSessionCorrelation() { + if (sessionCorrelationId.isEmpty()) { + Logger.verbose(TAG, "persistSessionCorrelation: skipped — no sessionCorrelationId") + return + } + + try { + val prefs = appContext.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE) + val existing = prefs.getString(PREFS_FILE, "") + val cache = if (!existing.isNullOrEmpty()) JSONObject(existing) else JSONObject() + + val key = "$clientId|$target" // target = sorted, space-joined scopes + val entry = JSONObject().apply { + put(FIELD_ID, sessionCorrelationId) + put(FIELD_TS, System.currentTimeMillis()) + } + cache.put(key, entry) + + prefs.edit().putString(PREFS_FILE, cache.toString()).apply() + Logger.verbose( + TAG, + sessionCorrelationId, + "Persisted session correlation entry for key=$key" + ) + } catch (e: JSONException) { + Logger.warn(TAG, sessionCorrelationId, "Failed to persist session correlation entry: " + e.message) + } + } + + companion object { + private val TAG = OnboardingTelemetryRecorder::class.java.simpleName + private const val PREFS_FILE = "com.microsoft.oneauth.session_correlation_cache" + private const val EMPTY_BLOB = "" + private const val ISO_TIMESTAMP_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + + // Seed field key constants — must match OnboardingBlobConstants (Djinni-generated). + // Duplicated here to avoid a dependency on the Djinni-generated Java class in Common. + private const val FIELD_SCHEMA_VERSION = "schema_version" + private const val FIELD_SESSION_CORRELATION_ID = "session_correlation_id" + private const val FIELD_ONBOARDING_MODE = "onboarding_mode" + private const val FIELD_STEPS_LIST = "steps_list" + private const val FIELD_STEP_ID = "step_id" + private const val FIELD_TS = "ts" + private const val FIELD_ID = "id" + } +} diff --git a/common/src/test/java/com/microsoft/identity/common/internal/request/MsalBrokerRequestAdapterTests.java b/common/src/test/java/com/microsoft/identity/common/internal/request/MsalBrokerRequestAdapterTests.java index 11a9c9580f..ff9f81a36a 100644 --- a/common/src/test/java/com/microsoft/identity/common/internal/request/MsalBrokerRequestAdapterTests.java +++ b/common/src/test/java/com/microsoft/identity/common/internal/request/MsalBrokerRequestAdapterTests.java @@ -386,4 +386,73 @@ public void testGetRequestBundleForAadDeviceIdRequest() { assertEquals(mockRedirectUri, brokerRequest.getRedirect()); assertEquals(mockTenantId, brokerRequest.getTenantId()); } + + /** + * Verify that {@code onboardingSeedJson} from {@link InteractiveTokenCommandParameters} + * is propagated into {@link BrokerRequest} by + * {@link MsalBrokerRequestAdapter#brokerRequestFromAcquireTokenParameters(InteractiveTokenCommandParameters)}. + */ + @Test + public void test_brokerRequestFromAcquireTokenParameters_PropagatesOnboardingSeedJson() { + final String seedJson = "{\"schema_version\":\"1.0.0\"," + + "\"session_correlation_id\":\"abc-123\"," + + "\"onboarding_mode\":\"brokered\"}"; + final Set scopes = new HashSet<>(); + scopes.add("user.read"); + + final IPlatformComponents components = MockPlatformComponentsFactory.getNonFunctionalBuilder().build(); + final AndroidInteractiveTokenCommandParameters params = AndroidInteractiveTokenCommandParameters.builder() + .platformComponents(components) + .correlationId("987d8962-3f4d-4054-a852-ac0c4b6a602e") + .clientId("aClientId") + .redirectUri("msauth://com.example/foo") + .applicationName("com.example") + .applicationVersion("1.0.0") + .sdkType(SdkType.MSAL) + .sdkVersion("5.4.0") + .authority(new AzureActiveDirectoryAuthority()) + .scopes(scopes) + .authenticationScheme(new BearerAuthenticationSchemeInternal()) + .prompt(OpenIdConnectPromptParameter.LOGIN) + .requiredBrokerProtocolVersion("10.0") + .onboardingSeedJson(seedJson) + .build(); + + final BrokerRequest brokerRequest = + new MsalBrokerRequestAdapter().brokerRequestFromAcquireTokenParameters(params); + + assertEquals(seedJson, brokerRequest.getOnboardingSeedJson()); + } + + /** + * Verify that when {@code onboardingSeedJson} is not set on the parameters, + * the resulting {@link BrokerRequest} carries a null seed (i.e. no accidental default value). + */ + @Test + public void test_brokerRequestFromAcquireTokenParameters_NoSeedJson_IsNull() { + final Set scopes = new HashSet<>(); + scopes.add("user.read"); + + final IPlatformComponents components = MockPlatformComponentsFactory.getNonFunctionalBuilder().build(); + final AndroidInteractiveTokenCommandParameters params = AndroidInteractiveTokenCommandParameters.builder() + .platformComponents(components) + .correlationId("987d8962-3f4d-4054-a852-ac0c4b6a602e") + .clientId("aClientId") + .redirectUri("msauth://com.example/foo") + .applicationName("com.example") + .applicationVersion("1.0.0") + .sdkType(SdkType.MSAL) + .sdkVersion("5.4.0") + .authority(new AzureActiveDirectoryAuthority()) + .scopes(scopes) + .authenticationScheme(new BearerAuthenticationSchemeInternal()) + .prompt(OpenIdConnectPromptParameter.LOGIN) + .requiredBrokerProtocolVersion("10.0") + .build(); + + final BrokerRequest brokerRequest = + new MsalBrokerRequestAdapter().brokerRequestFromAcquireTokenParameters(params); + + assertNull(brokerRequest.getOnboardingSeedJson()); + } } diff --git a/common/src/test/java/com/microsoft/identity/common/internal/request/MsalBrokerResultAdapterTests.kt b/common/src/test/java/com/microsoft/identity/common/internal/request/MsalBrokerResultAdapterTests.kt index 09c720ab7a..768333b324 100644 --- a/common/src/test/java/com/microsoft/identity/common/internal/request/MsalBrokerResultAdapterTests.kt +++ b/common/src/test/java/com/microsoft/identity/common/internal/request/MsalBrokerResultAdapterTests.kt @@ -658,4 +658,34 @@ class MsalBrokerResultAdapterTests { resultString.contains(SchemaUtil.MISSING_FROM_THE_TOKEN_RESPONSE) ) } + + @Test + fun testOnboardingBlob_RoundTripsThroughBundle() { + val blobJson = """{"schema_version":"1.0.0","session_correlation_id":"abc-123","onboarding_mode":"brokered","blocking_errors":["BROKER_INSTALLATION_TRIGGERED"]}""" + val brokerResult = BrokerResult.Builder() + .clientId("aClientId") + .correlationId("987d8962-3f4d-4054-a852-ac0c4b6a602e") + .onboardingBlob(blobJson) + .build() + + val adapter = getInstance() + val resultBundle = adapter.bundleFromBrokerResult(brokerResult, "10.0") + val deserialized = adapter.brokerResultFromBundle(resultBundle) + + assertEquals(blobJson, deserialized.onboardingBlob) + } + + @Test + fun testOnboardingBlob_NotSet_DeserializesAsNull() { + val brokerResult = BrokerResult.Builder() + .clientId("aClientId") + .correlationId("987d8962-3f4d-4054-a852-ac0c4b6a602e") + .build() + + val adapter = getInstance() + val resultBundle = adapter.bundleFromBrokerResult(brokerResult, "10.0") + val deserialized = adapter.brokerResultFromBundle(resultBundle) + + assertNull(deserialized.onboardingBlob) + } } \ No newline at end of file diff --git a/common/src/test/java/com/microsoft/identity/common/internal/telemetry/OnboardingSessionCorrelationStoreTest.kt b/common/src/test/java/com/microsoft/identity/common/internal/telemetry/OnboardingSessionCorrelationStoreTest.kt new file mode 100644 index 0000000000..2c68ec5212 --- /dev/null +++ b/common/src/test/java/com/microsoft/identity/common/internal/telemetry/OnboardingSessionCorrelationStoreTest.kt @@ -0,0 +1,69 @@ +// 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.test.core.app.ApplicationProvider +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class OnboardingSessionCorrelationStoreTest { + + private lateinit var store: OnboardingSessionCorrelationStore + + @Before + fun setup() { + store = OnboardingSessionCorrelationStore( + ApplicationProvider.getApplicationContext() + ) + } + + @Test + fun testLoad_Empty_ReturnsEmptyString() { + Assert.assertEquals("", store.load()) + } + + @Test + fun testSaveAndLoad() { + val json = "{\"key|scope\":{\"id\":\"uuid\",\"ts\":1234567890}}" + store.save(json) + Assert.assertEquals(json, store.load()) + } + + @Test + fun testSave_OverwritesPrevious() { + store.save("first") + store.save("second") + Assert.assertEquals("second", store.load()) + } + + @Test + fun testSave_EmptyString() { + store.save("some data") + store.save("") + Assert.assertEquals("", store.load()) + } +} diff --git a/common/src/test/java/com/microsoft/identity/common/internal/telemetry/OnboardingTelemetryRecorderTest.kt b/common/src/test/java/com/microsoft/identity/common/internal/telemetry/OnboardingTelemetryRecorderTest.kt new file mode 100644 index 0000000000..3facf2f852 --- /dev/null +++ b/common/src/test/java/com/microsoft/identity/common/internal/telemetry/OnboardingTelemetryRecorderTest.kt @@ -0,0 +1,216 @@ +// 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 android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.microsoft.identity.common.java.telemetry.OnboardingTelemetryConstants +import org.json.JSONObject +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class OnboardingTelemetryRecorderTest { + + private lateinit var recorder: OnboardingTelemetryRecorder + + @Before + fun setup() { + recorder = OnboardingTelemetryRecorder( + SEED_JSON, CLIENT_ID, TARGET, + ApplicationProvider.getApplicationContext() + ) + } + + // --- Constructor / seed parsing --- + + @Test + fun testGetSessionCorrelationId() { + Assert.assertEquals("test-uuid-123", recorder.sessionCorrelationId) + } + + @Test + fun testConstructorWithCorruptedSeedJson() { + val r = OnboardingTelemetryRecorder( + "not valid json", CLIENT_ID, TARGET, + ApplicationProvider.getApplicationContext() + ) + Assert.assertEquals("", r.sessionCorrelationId) + } + + @Test + fun testConstructorWithEmptySeedJson() { + val r = OnboardingTelemetryRecorder( + "{}", CLIENT_ID, TARGET, + ApplicationProvider.getApplicationContext() + ) + Assert.assertEquals("", r.sessionCorrelationId) + } + + // --- finalizeBlob --- + + @Test + fun testFinalizeBlob_NoBlockingErrors_ReturnsEmpty() { + Assert.assertEquals("", recorder.finalizeBlob()) + } + + @Test + fun testFinalizeBlob_WithBlockingError_ReturnsPopulatedJson() { + recorder.addBlockingError("BROKER_INSTALLATION_TRIGGERED") + + val result = recorder.finalizeBlob() + Assert.assertFalse(result.isEmpty()) + + val blob = JSONObject(result) + Assert.assertEquals("1.0.0", blob.getString("schema_version")) + Assert.assertEquals("test-uuid-123", blob.getString("session_correlation_id")) + Assert.assertEquals("non-brokered", blob.getString("onboarding_mode")) + + val errors = blob.getJSONArray("blocking_errors") + Assert.assertEquals(1, errors.length()) + Assert.assertEquals("BROKER_INSTALLATION_TRIGGERED", errors.getString(0)) + Assert.assertEquals("BROKER_INSTALLATION_TRIGGERED", blob.getString("last_blocking_error")) + } + + @Test + fun testFinalizeBlob_MultipleBlockingErrors() { + recorder.addBlockingError("BROKER_INSTALLATION_TRIGGERED") + recorder.addBlockingError("MDM_FLOW") + + val blob = JSONObject(recorder.finalizeBlob()) + val errors = blob.getJSONArray("blocking_errors") + Assert.assertEquals(2, errors.length()) + Assert.assertEquals("MDM_FLOW", blob.getString("last_blocking_error")) + } + + @Test + fun testFinalizeBlob_ContainsSeedFields() { + recorder.addBlockingError("BROKER_INSTALLATION_TRIGGERED") + + val blob = JSONObject(recorder.finalizeBlob()) + Assert.assertEquals("1.0.0", blob.getString("schema_version")) + Assert.assertEquals("test-uuid-123", blob.getString("session_correlation_id")) + Assert.assertEquals("non-brokered", blob.getString("onboarding_mode")) + } + + // --- addStep --- + + @Test + fun testAddStep_AppearsInFinalizedBlob() { + recorder.addBlockingError("BROKER_INSTALLATION_TRIGGERED") + recorder.addStep(OnboardingTelemetryConstants.STEP_AUTHENTICATION_STARTED) + recorder.addStep(OnboardingTelemetryConstants.STEP_BROKER_INSTALL_PROMPTED) + + val blob = JSONObject(recorder.finalizeBlob()) + val steps = blob.getJSONArray("steps_list") + Assert.assertEquals(2, steps.length()) + Assert.assertEquals("AuthenticationStarted", steps.getJSONObject(0).getString("step_id")) + Assert.assertTrue(steps.getJSONObject(0).has("ts")) + Assert.assertEquals("BrokerInstallPrompted", steps.getJSONObject(1).getString("step_id")) + Assert.assertTrue(steps.getJSONObject(1).has("ts")) + } + + @Test + fun testLastCompletedStep_SetAutomatically() { + recorder.addBlockingError("BROKER_INSTALLATION_TRIGGERED") + recorder.addStep(OnboardingTelemetryConstants.STEP_AUTHENTICATION_STARTED) + recorder.addStep(OnboardingTelemetryConstants.STEP_BROKER_INSTALL_PROMPTED) + + val blob = JSONObject(recorder.finalizeBlob()) + Assert.assertEquals("BrokerInstallPrompted", blob.getString("last_completed_step")) + } + + // --- setLastLoadedDomain --- + + @Test + fun testSetLastLoadedDomain() { + recorder.addBlockingError("BROKER_INSTALLATION_TRIGGERED") + recorder.setLastLoadedDomain("login.microsoftonline.com") + + val blob = JSONObject(recorder.finalizeBlob()) + Assert.assertEquals("login.microsoftonline.com", blob.getString("last_loaded_domain")) + } + + @Test + fun testLastLoadedDomain_NotSetByDefault() { + recorder.addBlockingError("BROKER_INSTALLATION_TRIGGERED") + + val blob = JSONObject(recorder.finalizeBlob()) + Assert.assertFalse(blob.has("last_loaded_domain")) + } + + // --- setProfile --- + + @Test + fun testSetProfile() { + recorder.addBlockingError("BROKER_INSTALLATION_TRIGGERED") + recorder.setProfile(OnboardingTelemetryConstants.PROFILE_WORK) + + val blob = JSONObject(recorder.finalizeBlob()) + Assert.assertEquals("workProfile", blob.getString("profile")) + } + + // --- addUxFlowUsed --- + + @Test + fun testAddUxFlowUsed() { + recorder.addBlockingError("BROKER_INSTALLATION_TRIGGERED") + recorder.addUxFlowUsed("MobileOnboardingPhase1") + + val blob = JSONObject(recorder.finalizeBlob()) + val flows = blob.getJSONArray("ux_flow_used") + Assert.assertEquals(1, flows.length()) + Assert.assertEquals("MobileOnboardingPhase1", flows.getString(0)) + } + + // --- SharedPreferences persistence --- + + @Test + fun testAddBlockingError_PersistsToSharedPreferences() { + recorder.addBlockingError("BROKER_INSTALLATION_TRIGGERED") + + val prefs = ApplicationProvider.getApplicationContext() + .getSharedPreferences( + "com.microsoft.oneauth.session_correlation_cache", + Context.MODE_PRIVATE + ) + val cached = prefs.getString("com.microsoft.oneauth.session_correlation_cache", "") ?: "" + Assert.assertFalse("SharedPreferences should contain cached session data", cached.isEmpty()) + Assert.assertTrue( + "Cached data should contain the session correlation ID", + cached.contains("test-uuid-123") + ) + } + + companion object { + private const val SEED_JSON = + "{\"schema_version\":\"1.0.0\"," + + "\"session_correlation_id\":\"test-uuid-123\"," + + "\"onboarding_mode\":\"non-brokered\"}" + private const val CLIENT_ID = "test-client-id" + private const val TARGET = "scope1 scope2" + } +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/commands/parameters/InteractiveTokenCommandParameters.java b/common4j/src/main/com/microsoft/identity/common/java/commands/parameters/InteractiveTokenCommandParameters.java index e31b9c7e40..22b40fbd2f 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/commands/parameters/InteractiveTokenCommandParameters.java +++ b/common4j/src/main/com/microsoft/identity/common/java/commands/parameters/InteractiveTokenCommandParameters.java @@ -38,6 +38,8 @@ import lombok.Getter; import lombok.experimental.SuperBuilder; +import edu.umd.cs.findbugs.annotations.Nullable; + @Getter @EqualsAndHashCode(callSuper = true) @SuperBuilder(toBuilder = true) @@ -85,6 +87,13 @@ public class InteractiveTokenCommandParameters extends TokenCommandParameters { */ private final boolean suppressBrokerAccountPicker; + /** + * Onboarding telemetry seed JSON blob. + * Passed through IPC to the broker for step recording and blocking error tracking. + */ + @Nullable + private final String onboardingSeedJson; + public boolean getHandleNullTaskAffinity(){ return handleNullTaskAffinity; } diff --git a/common4j/src/main/com/microsoft/identity/common/java/result/AcquireTokenResult.java b/common4j/src/main/com/microsoft/identity/common/java/result/AcquireTokenResult.java index 28fd66a2a3..5dd7d2622e 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/result/AcquireTokenResult.java +++ b/common4j/src/main/com/microsoft/identity/common/java/result/AcquireTokenResult.java @@ -49,6 +49,21 @@ public class AcquireTokenResult implements IBrokerPerformanceMetricsProvider, IB private BrokerPerformanceMetrics mBrokerPerformanceMetrics; + /** + * Populated onboarding telemetry blob JSON returned by the broker. + */ + @Nullable + private String mOnboardingBlob; + + public void setOnboardingBlob(@Nullable final String onboardingBlob) { + this.mOnboardingBlob = onboardingBlob; + } + + @Nullable + public String getOnboardingBlob() { + return this.mOnboardingBlob; + } + public void setLocalAuthenticationResult(ILocalAuthenticationResult result) { this.mLocalAuthenticationResult = result; this.mSucceeded = true; diff --git a/common4j/src/main/com/microsoft/identity/common/java/telemetry/OnboardingTelemetryConstants.kt b/common4j/src/main/com/microsoft/identity/common/java/telemetry/OnboardingTelemetryConstants.kt new file mode 100644 index 0000000000..9b368921fb --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/telemetry/OnboardingTelemetryConstants.kt @@ -0,0 +1,103 @@ +// 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.java.telemetry + +/** + * JSON field keys for the onboarding telemetry blob. + * All keys use snake_case to match MATS convention — EntityStore prepends "mo_" + * to produce the final MATS column name (e.g., "blocking_errors" → "mo_blocking_errors"). + * Seed creation + aggregation keys come from OnboardingBlobConstants (Djinni-generated). + */ +object OnboardingTelemetryConstants { + // Field keys for populated blob (written by OnboardingTelemetryRecorder, read by EntityStore with mo_ prefix) + const val BLOCKING_ERRORS = "blocking_errors" + const val LAST_BLOCKING_ERROR = "last_blocking_error" + const val LAST_LOADED_DOMAIN = "last_loaded_domain" + const val LAST_COMPLETED_STEP = "last_completed_step" + const val PROFILE = "profile" + const val UX_FLOW_USED = "ux_flow_used" + + // Step ID values not used in C++ aggregation (no derived duration metric computed from these) + const val STEP_AUTHENTICATION_STARTED = "AuthenticationStarted" + const val STEP_ACCOUNT_SELECTION_STARTED = "AccountSelectionStarted" + const val STEP_CREDENTIAL_ENTRY_COMPLETED = "CredentialEntryCompleted" + const val STEP_PASSKEY_AUTH_STARTED = "PasskeyAuthStarted" + const val STEP_CERT_BASED_AUTH_STARTED = "CertBasedAuthStarted" + + // MFA / Strong Auth Setup + const val STEP_STRONG_AUTH_SETUP_STARTED = "StrongAuthSetupStarted" + const val STEP_STRONG_AUTH_SETUP_COMPLETED = "StrongAuthSetupCompleted" + const val STEP_AUTHENTICATOR_MFA_LINKING_STARTED = "AuthenticatorMfaLinkingStarted" + + // Conditional Access Block & Remediation + const val STEP_CA_BLOCK_RECEIVED = "CABlockReceived" + const val STEP_INTERRUPT_FLOW_STARTED = "InterruptFlowStarted" + const val STEP_CONSENT_PROMPT_SHOWN = "ConsentPromptShown" + const val STEP_TERMS_OF_USE_SHOWN = "TermsOfUseShown" + const val STEP_PASSWORD_RESET_REQUIRED = "PasswordResetRequired" + + // Broker Installation + const val STEP_BROKER_INSTALL_PROMPTED = "BrokerInstallPrompted" + const val STEP_BROKER_INSTALL_PROMPTED_FOR_MDM = "BrokerInstallPromptedForMDM" + + // Device Registration (WPJ) + const val STEP_DEVICE_REGISTRATION_STARTED = "DeviceRegistrationStarted" + const val STEP_DEVICE_REGISTRATION_COMPLETED = "DeviceRegistrationCompleted" + const val STEP_DEVICE_REGISTRATION_UPGRADE_STARTED = "DeviceRegistrationUpgradeStarted" + + // MDM Enrollment (PP → WP transition) + const val STEP_MDM_ENROLLMENT_STARTED = "MDMEnrollmentStarted" + const val STEP_COMPANY_PORTAL_LAUNCHED = "CompanyPortalLaunched" + const val STEP_WEB_CP_ENROLLMENT_STARTED = "WebCpEnrollmentStarted" + const val STEP_GOOGLE_ENROLLMENT_STARTED = "GoogleEnrollmentStarted" + + // Intune App Protection (MAM) + const val STEP_INTUNE_APP_PROTECTION_REQUIRED = "IntuneAppProtectionRequired" + + // Compliance Remediation + const val STEP_COMPLIANCE_REMEDIATION_STARTED = "ComplianceRemediationStarted" + const val STEP_COMPLIANCE_REMEDIATION_COMPLETED = "ComplianceRemediationCompleted" + + // Token Acquisition & Completion + const val STEP_PRT_ACQUIRED = "PrtAcquired" + const val STEP_TOKEN_ISSUED = "TokenIssued" + const val STEP_FLOW_COMPLETED = "FlowCompleted" + + // Termination (Non-Success) + const val STEP_USER_CANCELED = "UserCanceled" + const val STEP_AUTHORIZATION_TIMED_OUT = "AuthorizationTimedOut" + + // Blocking error values — must match C++ hardcoded strings in InteractiveRequest.cpp + const val BLOCKING_ERROR_BROKER_INSTALL = "BROKER_INSTALLATION_TRIGGERED" + const val BLOCKING_ERROR_MDM_FLOW = "MDM_FLOW" + + // Device-registration blocking errors — one per BrokerExceptionClassifier.Category + // (see broker4j BrokerExceptionClassifier + InteractiveRequestAcquireTokenErrorHandler). + const val BLOCKING_ERROR_DEVICE_REGISTRATION_NEEDED = "DEVICE_REGISTRATION_NEEDED" + const val BLOCKING_ERROR_STRONG_DEVICE_REGISTRATION_NEEDED = "STRONG_DEVICE_REGISTRATION_NEEDED" + const val BLOCKING_ERROR_INSUFFICIENT_DEVICE_REGISTRATION = "INSUFFICIENT_DEVICE_REGISTRATION" + + // Platform-specific values + const val PROFILE_USER = "userProfile" + const val PROFILE_WORK = "workProfile" +} diff --git a/common4j/src/test/com/microsoft/identity/common/java/commands/parameters/InteractiveTokenCommandParametersTest.java b/common4j/src/test/com/microsoft/identity/common/java/commands/parameters/InteractiveTokenCommandParametersTest.java new file mode 100644 index 0000000000..e8b47372d0 --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/commands/parameters/InteractiveTokenCommandParametersTest.java @@ -0,0 +1,57 @@ +// 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.java.commands.parameters; + +import static org.mockito.Mockito.mock; + +import com.microsoft.identity.common.java.interfaces.IPlatformComponents; + +import org.junit.Assert; +import org.junit.Test; + +/** + * Tests for {@link InteractiveTokenCommandParameters} accessors, including the onboarding + * telemetry seed JSON field carried into the broker IPC request bundle. + */ +public class InteractiveTokenCommandParametersTest { + + @Test + public void onboardingSeedJson_DefaultsToNull() { + final InteractiveTokenCommandParameters params = InteractiveTokenCommandParameters.builder() + .platformComponents(mock(IPlatformComponents.class)) + .build(); + Assert.assertNull(params.getOnboardingSeedJson()); + } + + @Test + public void onboardingSeedJson_RoundTripsThroughBuilder() { + final String seedJson = "{\"schema_version\":\"1.0.0\"," + + "\"session_correlation_id\":\"abc-123\"," + + "\"onboarding_mode\":\"brokered\"}"; + final InteractiveTokenCommandParameters params = InteractiveTokenCommandParameters.builder() + .platformComponents(mock(IPlatformComponents.class)) + .onboardingSeedJson(seedJson) + .build(); + Assert.assertEquals(seedJson, params.getOnboardingSeedJson()); + } +} diff --git a/common4j/src/test/com/microsoft/identity/common/java/result/AcquireTokenResultTest.java b/common4j/src/test/com/microsoft/identity/common/java/result/AcquireTokenResultTest.java new file mode 100644 index 0000000000..dd0bd3d9ab --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/result/AcquireTokenResultTest.java @@ -0,0 +1,57 @@ +// 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.java.result; + +import org.junit.Assert; +import org.junit.Test; + +/** + * Tests for {@link AcquireTokenResult} accessors, including the onboarding telemetry blob + * field used to convey broker-side onboarding telemetry back to the client. + */ +public class AcquireTokenResultTest { + + @Test + public void onboardingBlob_DefaultsToNull() { + final AcquireTokenResult result = new AcquireTokenResult(); + Assert.assertNull(result.getOnboardingBlob()); + } + + @Test + public void onboardingBlob_RoundTripsThroughSetter() { + final String blobJson = "{\"schema_version\":\"1.0.0\"," + + "\"session_correlation_id\":\"abc-123\"," + + "\"onboarding_mode\":\"brokered\"}"; + final AcquireTokenResult result = new AcquireTokenResult(); + result.setOnboardingBlob(blobJson); + Assert.assertEquals(blobJson, result.getOnboardingBlob()); + } + + @Test + public void onboardingBlob_NullSetterClearsValue() { + final AcquireTokenResult result = new AcquireTokenResult(); + result.setOnboardingBlob("non-null"); + result.setOnboardingBlob(null); + Assert.assertNull(result.getOnboardingBlob()); + } +}