diff --git a/common/src/main/java/com/microsoft/identity/common/internal/activebrokerdiscovery/BrokerDiscoveryClient.kt b/common/src/main/java/com/microsoft/identity/common/internal/activebrokerdiscovery/BrokerDiscoveryClient.kt index 4ab2a32910..d1abfdfa9b 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/activebrokerdiscovery/BrokerDiscoveryClient.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/activebrokerdiscovery/BrokerDiscoveryClient.kt @@ -425,6 +425,7 @@ class BrokerDiscoveryClient(private val brokerCandidates: Set, if (brokerData != null) { cache.setCachedActiveBroker(brokerData) + refreshInMemoryCacheOnForceFresh(shouldSkipCache, brokerData) return brokerData } @@ -447,6 +448,30 @@ class BrokerDiscoveryClient(private val brokerCandidates: Set, "get ${accountManagerResult?.packageName}." ) + refreshInMemoryCacheOnForceFresh(shouldSkipCache, accountManagerResult) return accountManagerResult } + + /** + * Keeps the in-memory cache ([cachedData]) coherent with a force-fresh discovery. + * + * A force-fresh discovery ([getActiveBroker] with {@code shouldSkipCache = true}) is + * authoritative: it deliberately bypasses every cache and re-runs discovery. Its result must + * therefore also overwrite the in-memory cache, otherwise [getActiveBrokerWithInMemoryCache] + * keeps returning a stale value for the life of the process. In particular, a broker installed + * mid-process (the MAM broker-install request-resume case: Company Portal is installed while the + * calling app is alive) would never be surfaced, because the in-memory cache stays pinned to the + * earlier {@code CachedBrokerData(null)} ("no broker") result. + * + * Must be called while holding [classLevelLock] (as all [getActiveBrokerAsync] callers do), which + * matches how [getActiveBrokerWithInMemoryCache] writes [cachedData]. + * + * @param shouldSkipCache whether the discovery that produced [brokerData] was force-fresh. + * @param brokerData the freshly discovered active broker, or {@code null} if none was found. + */ + private fun refreshInMemoryCacheOnForceFresh(shouldSkipCache: Boolean, brokerData: BrokerData?) { + if (shouldSkipCache) { + cachedData = CachedBrokerData(brokerData) + } + } } \ No newline at end of file diff --git a/common/src/main/java/com/microsoft/identity/common/internal/commands/BrokerInstallResumeForegroundObserver.java b/common/src/main/java/com/microsoft/identity/common/internal/commands/BrokerInstallResumeForegroundObserver.java new file mode 100644 index 0000000000..0ad75d62c6 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/internal/commands/BrokerInstallResumeForegroundObserver.java @@ -0,0 +1,131 @@ +// 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.commands; + +import android.app.Activity; +import android.app.Application; +import android.os.Bundle; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.microsoft.identity.common.java.logging.Logger; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Detects when the host app returns to the foreground and drives the MAM broker-install resume + * foreground-fallback (§16 item 13). This is the trigger that lets a parked request resume when Company + * Portal simply brings the calling app back (its existing redirect-back), or when the user manually swipes + * back to the app after installing Company Portal — no {@code mam_resume=} redirect required. + *

+ * Implemented with {@link Application.ActivityLifecycleCallbacks} (dependency-free) rather than + * {@code ProcessLifecycleOwner}. Callbacks run on the main thread, so the started-activity counter needs no + * synchronization. The transition from zero to one started activity is treated as "app foregrounded". + *

+ * The work done on foreground is cheap and short-circuits early: {@link BrokerInstallResumeManager} + * no-ops unless the flight is on and a request is currently parked, so registering this observer + * unconditionally at SDK init is safe. Register once via {@link #install(Application)}. + */ +public final class BrokerInstallResumeForegroundObserver + implements Application.ActivityLifecycleCallbacks { + + private static final String TAG = BrokerInstallResumeForegroundObserver.class.getSimpleName(); + + private static final AtomicBoolean INSTALLED = new AtomicBoolean(false); + + private final Application mApplication; + + /** Number of started (visible) activities. Main-thread confined. */ + private int mStartedActivityCount = 0; + + private BrokerInstallResumeForegroundObserver(@NonNull final Application application) { + mApplication = application; + } + + /** + * Registers the observer against the given application exactly once per process. Safe to call + * repeatedly and from multiple entry points; subsequent calls are no-ops. + * + * @param application the host application (no-op if {@code null}). + */ + public static void install(@Nullable final Application application) { + if (application == null) { + return; + } + if (INSTALLED.compareAndSet(false, true)) { + application.registerActivityLifecycleCallbacks( + new BrokerInstallResumeForegroundObserver(application)); + Logger.info(TAG + ":install", "Registered broker-install resume foreground observer."); + } + } + + @Override + @MainThread + public void onActivityStarted(@NonNull final Activity activity) { + if (mStartedActivityCount == 0) { + // Zero -> one: the app just came to the foreground. + try { + BrokerInstallResumeManager.getInstance().onAppForegrounded(mApplication); + } catch (final Throwable t) { + // Never let a resume attempt destabilize the host app's activity lifecycle. + Logger.warn(TAG + ":onActivityStarted", + "Foreground-fallback resume attempt failed (ignored)."); + } + } + mStartedActivityCount++; + } + + @Override + @MainThread + public void onActivityStopped(@NonNull final Activity activity) { + if (mStartedActivityCount > 0) { + mStartedActivityCount--; + } + } + + // region unused lifecycle callbacks + + @Override + public void onActivityCreated(@NonNull final Activity activity, @Nullable final Bundle savedInstanceState) { + } + + @Override + public void onActivityResumed(@NonNull final Activity activity) { + } + + @Override + public void onActivityPaused(@NonNull final Activity activity) { + } + + @Override + public void onActivitySaveInstanceState(@NonNull final Activity activity, @NonNull final Bundle outState) { + } + + @Override + public void onActivityDestroyed(@NonNull final Activity activity) { + } + + // endregion +} diff --git a/common/src/main/java/com/microsoft/identity/common/internal/commands/BrokerInstallResumeManager.java b/common/src/main/java/com/microsoft/identity/common/internal/commands/BrokerInstallResumeManager.java new file mode 100644 index 0000000000..780f208951 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/internal/commands/BrokerInstallResumeManager.java @@ -0,0 +1,325 @@ +// 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.commands; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import com.microsoft.identity.common.internal.broker.BrokerValidator; +import com.microsoft.identity.common.java.AuthenticationConstants; +import com.microsoft.identity.common.java.commands.BrokerInstallResumeCoordinator; +import com.microsoft.identity.common.java.commands.BrokerInstallResumeCoordinator.ISilentResumeSubmitter; +import com.microsoft.identity.common.java.commands.BrokerInstallResumeEngine; +import com.microsoft.identity.common.java.commands.BrokerInstallResumeRegistry; +import com.microsoft.identity.common.java.commands.InteractiveTokenCommand; +import com.microsoft.identity.common.java.commands.ParkedRecord; +import com.microsoft.identity.common.java.exception.BrokerInstallationRequiredException; +import com.microsoft.identity.common.java.flighting.CommonFlight; +import com.microsoft.identity.common.java.flighting.CommonFlightsManager; +import com.microsoft.identity.common.java.flighting.IFlightsProvider; +import com.microsoft.identity.common.java.logging.Logger; +import com.microsoft.identity.common.java.opentelemetry.BrokerInstallResumeTelemetryHelper; +import com.microsoft.identity.common.java.providers.microsoft.MicrosoftAuthorizationErrorResponse; +import com.microsoft.identity.common.java.util.StringUtil; + +import java.util.List; + +/** + * Android entry point that drives the MAM broker-install request-resume plumbing (Phase 5): it decides + * whether a parked interactive request may be resumed, validates that the trigger genuinely comes from + * Company Portal, and hands the resume off to the common4j + * {@link BrokerInstallResumeCoordinator}. It owns none of the token machinery itself — the actual + * force-fresh broker discovery ({@code getActiveBroker(shouldSkipCache=true)}) + silent submit is supplied + * by the platform/consumer (MSAL / OneAuth) via {@link #registerSilentResumeSubmitter(ISilentResumeSubmitter)}. + *

+ * Two resume triggers are supported: + *

    + *
  • Automatic — {@link #onResumeRedirect(Context, String, String)}: Company Portal redirects to + * {@code ?mam_resume=}; the {@code cid} selects the exact parked request.
  • + *
  • Foreground fallback — {@link #onAppForegrounded(Context)}: the app returns to the foreground + * (e.g. Company Portal's existing redirect-back, or the user swiping back) with no {@code cid}; every + * still-parked request is resumed if Company Portal is now a valid broker. This is the path that makes + * the flow testable without any Company Portal change (§16 item 13).
  • + *
+ * Everything is gated behind {@link CommonFlight#ENABLE_BROKER_INSTALL_RESUME}; with the flight off every + * method is an immediate no-op. + */ +public final class BrokerInstallResumeManager { + + private static final String TAG = BrokerInstallResumeManager.class.getSimpleName(); + + private static final BrokerInstallResumeManager INSTANCE = new BrokerInstallResumeManager(); + + /** + * Platform-supplied silent retry (fresh discovery + silent submit). {@code volatile} because it is + * registered on an init thread and read on redirect/foreground threads. + */ + @Nullable + private volatile ISilentResumeSubmitter mSubmitter; + + private BrokerInstallResumeManager() { + } + + public static BrokerInstallResumeManager getInstance() { + return INSTANCE; + } + + /** + * @return a fresh, isolated instance for unit tests (so a registered submitter does not bleed across + * tests). Production code must use {@link #getInstance()}. + */ + @VisibleForTesting + static BrokerInstallResumeManager newInstanceForTesting() { + return new BrokerInstallResumeManager(); + } + + /** + * Registers the platform silent-retry implementation. MSAL / OneAuth call this once at init so the + * common module can drive the resume without owning the controller factory. If never registered, a + * resume trigger resolves the parked request with the original install-required error (never hangs). + * + * @param submitter the platform silent retry; must force-fresh broker discovery. + */ + public void registerSilentResumeSubmitter(@NonNull final ISilentResumeSubmitter submitter) { + mSubmitter = submitter; + } + + /** + * Company Portal trust check, abstracted for unit-testability. The production implementation is backed + * by {@link BrokerValidator}. + */ + public interface ICompanyPortalTrust { + /** @return {@code true} if {@code packageName} is Company Portal and is validly signed/installed. */ + boolean isTrustedCompanyPortal(@Nullable String packageName); + + /** @return {@code true} if Company Portal is currently installed as a valid broker. */ + boolean isCompanyPortalInstalledAndValid(); + } + + // region production entry points (Android) + + /** + * Automatic resume: a {@code mam_resume=} redirect arrived from Company Portal. + * + * @param context any Android context (used to validate the caller is Company Portal). + * @param correlationId the {@code cid} echoed back by Company Portal. + * @param callerPackage the package that delivered the redirect, if known (validated against CP). + * @return {@code true} if a parked request was selected and its resume driven; {@code false} otherwise. + */ + public boolean onResumeRedirect(@NonNull final Context context, + @NonNull final String correlationId, + @Nullable final String callerPackage) { + return onResumeRedirect(correlationId, callerPackage, flights(), + new BrokerValidatorCompanyPortalTrust(context), BrokerInstallResumeRegistry.getInstance()); + } + + /** + * Automatic resume where the caller package is NOT available (e.g. a custom-tab / browser redirect to + * the app's registered redirect URI — the browser does not expose which app produced it). Trust is + * established by capability instead: the {@code cid} must match a request this process actually + * parked (an unguessable per-request UUIDv4 that only reached Company Portal via the install referrer), + * and Company Portal must now be installed as a valid broker. + * + * @param context any Android context (used to check Company Portal is now a valid broker). + * @param correlationId the {@code cid} carried on the resume redirect. + * @return {@code true} if a parked request was selected and its resume driven; {@code false} otherwise. + */ + public boolean onResumeRedirect(@NonNull final Context context, + @NonNull final String correlationId) { + return onResumeRedirectByCapability(correlationId, flights(), + new BrokerValidatorCompanyPortalTrust(context), BrokerInstallResumeRegistry.getInstance()); + } + + /** + * Foreground fallback: the app came to the foreground; resume any parked request if Company Portal is + * now a valid broker. + * + * @param context any Android context (used to check Company Portal is now installed and valid). + * @return the number of parked requests whose resume was driven by this call. + */ + public int onAppForegrounded(@NonNull final Context context) { + return onAppForegrounded(flights(), new BrokerValidatorCompanyPortalTrust(context), + BrokerInstallResumeRegistry.getInstance()); + } + + // endregion + + // region test-visible core (no Android dependencies) + + @VisibleForTesting + boolean onResumeRedirect(@NonNull final String correlationId, + @Nullable final String callerPackage, + @NonNull final IFlightsProvider flights, + @NonNull final ICompanyPortalTrust cpTrust, + @NonNull final BrokerInstallResumeRegistry registry) { + if (!isEnabled(flights) || StringUtil.isNullOrEmpty(correlationId)) { + return false; + } + // Trust anchor: only Company Portal may trigger a resume redirect (§7 caller validation). + if (!cpTrust.isTrustedCompanyPortal(callerPackage)) { + Logger.warn(TAG + ":onResumeRedirect", + "Resume redirect rejected: caller is not a trusted Company Portal."); + return false; + } + final ParkedRecord record = registry.match(correlationId); + if (record == null) { + // Resume arrived but nothing is parked -> process death during install (funnel indicator). + final BrokerInstallResumeTelemetryHelper telemetry = new BrokerInstallResumeTelemetryHelper(); + telemetry.setCorrelationId(correlationId); + telemetry.onResumeReceivedNoPark(); + Logger.info(TAG + ":onResumeRedirect", + "Resume redirect matched no parked request (likely process death during install)."); + return false; + } + return resumeRecord(record); + } + + @VisibleForTesting + boolean onResumeRedirectByCapability(@NonNull final String correlationId, + @NonNull final IFlightsProvider flights, + @NonNull final ICompanyPortalTrust cpTrust, + @NonNull final BrokerInstallResumeRegistry registry) { + if (!isEnabled(flights) || StringUtil.isNullOrEmpty(correlationId)) { + return false; + } + // Capability trust: only proceed if Company Portal is now a valid broker. The cid match below is + // the unforgeable half — a caller that does not know the parked cid cannot select a request. + if (!cpTrust.isCompanyPortalInstalledAndValid()) { + Logger.warn(TAG + ":onResumeRedirectByCapability", + "Resume redirect rejected: Company Portal is not (yet) a valid broker."); + return false; + } + final ParkedRecord record = registry.match(correlationId); + if (record == null) { + final BrokerInstallResumeTelemetryHelper telemetry = new BrokerInstallResumeTelemetryHelper(); + telemetry.setCorrelationId(correlationId); + telemetry.onResumeReceivedNoPark(); + Logger.info(TAG + ":onResumeRedirectByCapability", + "Resume redirect matched no parked request (likely process death during install)."); + return false; + } + return resumeRecord(record); + } + + @VisibleForTesting + int onAppForegrounded(@NonNull final IFlightsProvider flights, + @NonNull final ICompanyPortalTrust cpTrust, + @NonNull final BrokerInstallResumeRegistry registry) { + if (!isEnabled(flights) || registry.isEmpty()) { + return 0; + } + if (!cpTrust.isCompanyPortalInstalledAndValid()) { + // Company Portal is not yet a valid broker; leave the request parked (it resolves on TTL). + return 0; + } + final List pending = registry.claimAllPending(); + int resumed = 0; + for (final ParkedRecord record : pending) { + if (resumeRecord(record)) { + resumed++; + } + } + if (resumed > 0) { + Logger.info(TAG + ":onAppForegrounded", + "Drove foreground-fallback resume for " + resumed + " parked request(s)."); + } + return resumed; + } + + // endregion + + /** + * Drives the resume of a single parked record: builds a funnel span, and either hands off to the + * coordinator (if a platform submitter is registered) or resolves the parked sink with the original + * install-required error so the caller never hangs. + */ + private boolean resumeRecord(@NonNull final ParkedRecord record) { + final BrokerInstallResumeTelemetryHelper telemetry = new BrokerInstallResumeTelemetryHelper(); + telemetry.setCorrelationId(correlationIdOf(record)); + + final ISilentResumeSubmitter submitter = mSubmitter; + if (submitter == null) { + Logger.warn(TAG + ":resumeRecord", + "No silent-resume submitter registered; resolving parked request with the original " + + "install-required error."); + telemetry.onFailed(BrokerInstallResumeTelemetryHelper.STAGE_RESUME_RECEIVED, + "no_submitter_registered"); + return BrokerInstallResumeEngine.deliverError(record, installRequiredError(record)); + } + return BrokerInstallResumeCoordinator.resume(record, submitter, telemetry); + } + + private static boolean isEnabled(@NonNull final IFlightsProvider flights) { + return flights.isFlightEnabled(CommonFlight.ENABLE_BROKER_INSTALL_RESUME); + } + + @NonNull + private static IFlightsProvider flights() { + return CommonFlightsManager.INSTANCE.getFlightsProvider(); + } + + @Nullable + private static String correlationIdOf(@NonNull final ParkedRecord record) { + final InteractiveTokenCommand command = record.getInteractiveTokenCommand(); + return command == null ? null : command.getCorrelationId(); + } + + @NonNull + private static BrokerInstallationRequiredException installRequiredError(@NonNull final ParkedRecord record) { + return new BrokerInstallationRequiredException( + MicrosoftAuthorizationErrorResponse.BROKER_NEEDS_TO_BE_INSTALLED, + "Broker installation is required to complete this request.", + record.getUpn(), + null /* installLink not carried here */); + } + + /** + * Production {@link ICompanyPortalTrust} backed by {@link BrokerValidator}: a package is trusted only if + * it is exactly the Company Portal package name and is installed with a known-good signature. + */ + private static final class BrokerValidatorCompanyPortalTrust implements ICompanyPortalTrust { + + private final BrokerValidator mBrokerValidator; + + BrokerValidatorCompanyPortalTrust(@NonNull final Context context) { + mBrokerValidator = new BrokerValidator(context); + } + + @Override + public boolean isTrustedCompanyPortal(@Nullable final String packageName) { + return !StringUtil.isNullOrEmpty(packageName) + && AuthenticationConstants.Broker.COMPANY_PORTAL_APP_PACKAGE_NAME + .equalsIgnoreCase(packageName) + && mBrokerValidator.isValidBrokerPackage(packageName); + } + + @Override + public boolean isCompanyPortalInstalledAndValid() { + return mBrokerValidator.isValidBrokerPackage( + AuthenticationConstants.Broker.COMPANY_PORTAL_APP_PACKAGE_NAME); + } + } +} diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/BrowserAuthorizationFragment.java b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/BrowserAuthorizationFragment.java index 9c25b3d289..58792d2e1d 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/BrowserAuthorizationFragment.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/BrowserAuthorizationFragment.java @@ -34,9 +34,13 @@ import com.microsoft.identity.common.internal.telemetry.Telemetry; import com.microsoft.identity.common.internal.telemetry.events.UiEndEvent; +import com.microsoft.identity.common.internal.commands.BrokerInstallResumeManager; import com.microsoft.identity.common.java.util.StringUtil; import com.microsoft.identity.common.java.exception.ClientException; import com.microsoft.identity.common.java.exception.ErrorStrings; +import com.microsoft.identity.common.java.flighting.CommonFlight; +import com.microsoft.identity.common.java.flighting.CommonFlightsManager; +import com.microsoft.identity.common.java.providers.MamInstallReferrerBuilder; import com.microsoft.identity.common.java.providers.RawAuthorizationResult; import com.microsoft.identity.common.java.util.UrlUtil; import com.microsoft.identity.common.logging.Logger; @@ -181,10 +185,22 @@ private void completeAuthorizationInBrowserFlow(@NonNull final String customTabR case BROKER_INSTALLATION_TRIGGERED: final Map urlQueryParameters = UrlUtil.getParameters(data.getAuthorizationFinalUri()); final String appLink = urlQueryParameters.get(APP_LINK_KEY); - final Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(appLink)); + final Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse( + decorateInstallLinkWithReferrer(appLink))); startActivity(browserIntent); break; + case BROKER_INSTALL_RESUME: + // MAM broker-install resume: Company Portal redirected the calling app back with + // mam_resume=. Route to the resume manager (capability trust: cid-match + CP-installed; + // the browser does not expose the caller package). No-op unless the flight is on. + final Context resumeContext = getContext(); + if (resumeContext != null) { + BrokerInstallResumeManager.getInstance().onResumeRedirect( + resumeContext.getApplicationContext(), data.getMamResumeCorrelationId()); + } + break; + case COMPLETED: Telemetry.emit(new UiEndEvent().isUiComplete()); break; @@ -200,4 +216,21 @@ private void completeAuthorizationInBrowserFlow(@NonNull final String customTabR sendResult(data); finish(); } + + /** + * MAM broker-install request-resume: when the flight is on, tag the Company Portal install link with + * the calling app package as the Play install referrer so Company Portal can redirect back to us after + * install (CP-confirmed {@code &referrer=} pattern). Flight-gated and null-safe; with the + * flight off (or no context) the original link is returned unchanged. + */ + private String decorateInstallLinkWithReferrer(final String appLink) { + final Context context = getContext(); + if (context != null + && CommonFlightsManager.INSTANCE.getFlightsProvider() + .isFlightEnabled(CommonFlight.ENABLE_BROKER_INSTALL_RESUME)) { + return MamInstallReferrerBuilder.decorateAppLinkWithOriginReferrer( + appLink, context.getPackageName()); + } + return appLink; + } } diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CurrentTaskBrowserAuthorizationFragment.java b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CurrentTaskBrowserAuthorizationFragment.java index 32ae504e7d..4be650864c 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CurrentTaskBrowserAuthorizationFragment.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/CurrentTaskBrowserAuthorizationFragment.java @@ -33,8 +33,12 @@ import com.microsoft.identity.common.internal.telemetry.Telemetry; import com.microsoft.identity.common.internal.telemetry.events.UiEndEvent; import com.microsoft.identity.common.internal.util.FindBugsConstants; +import com.microsoft.identity.common.internal.commands.BrokerInstallResumeManager; import com.microsoft.identity.common.java.exception.ClientException; import com.microsoft.identity.common.java.exception.ErrorStrings; +import com.microsoft.identity.common.java.flighting.CommonFlight; +import com.microsoft.identity.common.java.flighting.CommonFlightsManager; +import com.microsoft.identity.common.java.providers.MamInstallReferrerBuilder; import com.microsoft.identity.common.java.providers.RawAuthorizationResult; import com.microsoft.identity.common.java.util.UrlUtil; import com.microsoft.identity.common.logging.Logger; @@ -154,10 +158,22 @@ public void completeAuthorizationInBrowserFlow(@NonNull final String customTabRe case BROKER_INSTALLATION_TRIGGERED: final Map urlQueryParameters = UrlUtil.getParameters(data.getAuthorizationFinalUri()); final String appLink = urlQueryParameters.get(APP_LINK_KEY); - final Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(appLink)); + final Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse( + decorateInstallLinkWithReferrer(appLink))); startActivity(browserIntent); break; + case BROKER_INSTALL_RESUME: + // MAM broker-install resume: Company Portal redirected the calling app back with + // mam_resume=. Route to the resume manager (capability trust: cid-match + CP-installed; + // the browser does not expose the caller package). No-op unless the flight is on. + final Context resumeContext = getContext(); + if (resumeContext != null) { + BrokerInstallResumeManager.getInstance().onResumeRedirect( + resumeContext.getApplicationContext(), data.getMamResumeCorrelationId()); + } + break; + case COMPLETED: Telemetry.emit(new UiEndEvent().isUiComplete()); break; @@ -173,4 +189,21 @@ public void completeAuthorizationInBrowserFlow(@NonNull final String customTabRe sendResult(data); finish(); } + + /** + * MAM broker-install request-resume: when the flight is on, tag the Company Portal install link with + * the calling app package as the Play install referrer so Company Portal can redirect back to us after + * install (CP-confirmed {@code &referrer=} pattern). Flight-gated and null-safe; with the + * flight off (or no context) the original link is returned unchanged. + */ + private String decorateInstallLinkWithReferrer(final String appLink) { + final Context context = getContext(); + if (context != null + && CommonFlightsManager.INSTANCE.getFlightsProvider() + .isFlightEnabled(CommonFlight.ENABLE_BROKER_INSTALL_RESUME)) { + return MamInstallReferrerBuilder.decorateAppLinkWithOriginReferrer( + appLink, context.getPackageName()); + } + return appLink; + } } \ No newline at end of file diff --git a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java index 759a65145b..2f601bd768 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java @@ -83,6 +83,7 @@ import com.microsoft.identity.common.java.WarningType; import com.microsoft.identity.common.java.exception.ClientException; import com.microsoft.identity.common.java.exception.ErrorStrings; +import com.microsoft.identity.common.java.providers.MamInstallReferrerBuilder; import com.microsoft.identity.common.java.providers.RawAuthorizationResult; import static com.microsoft.identity.common.java.telemetry.OnboardingTelemetryConstants.STEP_AUTHENTICATOR_MFA_LINKING_STARTED; import static com.microsoft.identity.common.java.telemetry.OnboardingTelemetryConstants.STEP_BROKER_INSTALL_PROMPTED; @@ -1230,6 +1231,16 @@ private void processInstallRequest(@NonNull final WebView view, @NonNull final S public void run() { String link = appLink .replace(AuthenticationConstants.Broker.BROWSER_EXT_PREFIX, "https://"); + // MAM broker-install request-resume: tag the Company Portal install launch with the + // calling app package as the Play install referrer so Company Portal can redirect back + // to us after install (CP-confirmed &referrer= pattern). Flight-gated; with the + // flight off the link is launched exactly as before. + if (CommonFlightsManager.INSTANCE.getFlightsProvider() + .isFlightEnabled(CommonFlight.ENABLE_BROKER_INSTALL_RESUME) + && getActivity() != null) { + link = MamInstallReferrerBuilder.decorateAppLinkWithOriginReferrer( + link, getActivity().getPackageName()); + } Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(link)); getActivity().startActivity(intent); view.stopLoading(); diff --git a/common/src/test/java/com/microsoft/identity/common/internal/activebrokerdiscovery/BrokerDiscoveryClientTests.kt b/common/src/test/java/com/microsoft/identity/common/internal/activebrokerdiscovery/BrokerDiscoveryClientTests.kt index bd0cec6dd0..fca2f56339 100644 --- a/common/src/test/java/com/microsoft/identity/common/internal/activebrokerdiscovery/BrokerDiscoveryClientTests.kt +++ b/common/src/test/java/com/microsoft/identity/common/internal/activebrokerdiscovery/BrokerDiscoveryClientTests.kt @@ -947,6 +947,95 @@ class BrokerDiscoveryClientTests { Assert.assertEquals(1, accountManagerReadCount) } + /** + * MAM broker-install request-resume regression: a broker (Company Portal) installed mid-process + * must be picked up by a force-fresh discovery (getActiveBroker(shouldSkipCache = true)) so that a + * SUBSEQUENT getActiveBrokerWithInMemoryCache() returns it instead of the stale "no broker" result. + * + * Before the fix, getActiveBroker(shouldSkipCache = true) only refreshed the persistent cache and + * left the in-memory cache pinned to CachedBrokerData(null); the resume eligibility check (which + * reads via getActiveBrokerWithInMemoryCache) kept seeing "no broker", so the parked request could + * never complete through the freshly installed broker. + **/ + @Test + fun testForceFreshDiscoveryRefreshesInMemoryCache_BrokerInstalledMidProcess() { + val companyPortalInstalled = AtomicBoolean(false) + + val cache = object : InMemoryActiveBrokerCache() { + var readCount = 0 + override fun getCachedActiveBroker(): BrokerData? { + readCount++ + return super.getCachedActiveBroker() + } + } + + var accountManagerReadCount = 0 + val client = BrokerDiscoveryClient( + brokerCandidates = setOf( + prodMicrosoftAuthenticator, prodCompanyPortal + ), + getActiveBrokerFromAccountManager = { + accountManagerReadCount++ + null + }, + ipcStrategy = object : IIpcStrategy { + override fun communicateToBroker(bundle: BrokerOperationBundle): Bundle { + if (companyPortalInstalled.get() && + bundle.targetBrokerAppPackageName == prodCompanyPortal.packageName) { + val returnBundle = Bundle() + returnBundle.putString( + BrokerDiscoveryClient.ACTIVE_BROKER_PACKAGE_NAME_BUNDLE_KEY, + prodCompanyPortal.packageName + ) + returnBundle.putString( + BrokerDiscoveryClient.ACTIVE_BROKER_SIGNING_CERTIFICATE_THUMBPRINT_BUNDLE_KEY, + prodCompanyPortal.signingCertificateThumbprint + ) + return returnBundle + } + throw IllegalStateException() + } + override fun isSupportedByTargetedBroker(targetedBrokerPackageName: String): Boolean { + return true + } + override fun getType(): IIpcStrategy.Type { + return IIpcStrategy.Type.CONTENT_PROVIDER + } + }, + cache = cache, + isPackageInstalled = { brokerData -> + companyPortalInstalled.get() && brokerData == prodCompanyPortal + }, + isValidBroker = { brokerData -> + companyPortalInstalled.get() && brokerData == prodCompanyPortal + } + ) + + // 1) Before Company Portal is installed: the in-memory-cache read finds no broker and caches + // the "no broker" result. + Assert.assertNull(client.getActiveBrokerWithInMemoryCache(null)) + Assert.assertNotNull(client.cachedData) + Assert.assertNull(client.cachedData!!.brokerData) + + // The in-memory cache is sticky: a second read does not re-query. + Assert.assertNull(client.getActiveBrokerWithInMemoryCache(null)) + Assert.assertEquals(1, cache.readCount) + + // 2) Company Portal is installed mid-process (the broker-install detour completes). + companyPortalInstalled.set(true) + + // Without a force-fresh, the in-memory cache is still stale. + Assert.assertNull(client.getActiveBrokerWithInMemoryCache(null)) + + // 3) Force-fresh discovery — exactly what the broker-install resume path triggers. + Assert.assertEquals(prodCompanyPortal, client.getActiveBroker(true)) + + // 4) The in-memory cache is now coherent, so the eligibility read returns Company Portal. + Assert.assertNotNull(client.cachedData) + Assert.assertEquals(prodCompanyPortal, client.cachedData!!.brokerData) + Assert.assertEquals(prodCompanyPortal, client.getActiveBrokerWithInMemoryCache(null)) + } + /** * Test concurrent access to in-memory cache from multiple coroutines. * All coroutines should read from cache without triggering discovery flow or storage operations. diff --git a/common/src/test/java/com/microsoft/identity/common/internal/commands/BrokerInstallResumeManagerTest.java b/common/src/test/java/com/microsoft/identity/common/internal/commands/BrokerInstallResumeManagerTest.java new file mode 100644 index 0000000000..be048fea20 --- /dev/null +++ b/common/src/test/java/com/microsoft/identity/common/internal/commands/BrokerInstallResumeManagerTest.java @@ -0,0 +1,229 @@ +// 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.commands; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.microsoft.identity.common.internal.commands.BrokerInstallResumeManager.ICompanyPortalTrust; +import com.microsoft.identity.common.java.commands.BrokerInstallResumeCoordinator.ISilentResumeSubmitter; +import com.microsoft.identity.common.java.commands.BrokerInstallResumeRegistry; +import com.microsoft.identity.common.java.commands.CommandCallback; +import com.microsoft.identity.common.java.commands.InteractiveTokenCommand; +import com.microsoft.identity.common.java.commands.ParkedRecord; +import com.microsoft.identity.common.java.commands.parameters.InteractiveTokenCommandParameters; +import com.microsoft.identity.common.java.exception.BrokerInstallationRequiredException; +import com.microsoft.identity.common.java.flighting.CommonFlight; +import com.microsoft.identity.common.java.flighting.IFlightsProvider; +import com.microsoft.identity.common.java.interfaces.IPlatformComponents; + +import org.junit.Test; + +import java.util.Collections; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Unit tests for {@link BrokerInstallResumeManager}'s Android-independent core: the flight gate, Company + * Portal trust gate, registry lookup, and hand-off to the coordinator (Phase 5). + */ +public class BrokerInstallResumeManagerTest { + + private static final String CP = "com.microsoft.windowsintune.companyportal"; + + private static IFlightsProvider flights(final boolean enabled) { + final IFlightsProvider flights = mock(IFlightsProvider.class); + when(flights.isFlightEnabled(CommonFlight.ENABLE_BROKER_INSTALL_RESUME)).thenReturn(enabled); + return flights; + } + + private static ICompanyPortalTrust cpTrust(final boolean callerTrusted, final boolean installed) { + final ICompanyPortalTrust trust = mock(ICompanyPortalTrust.class); + when(trust.isTrustedCompanyPortal(any())).thenReturn(callerTrusted); + when(trust.isCompanyPortalInstalledAndValid()).thenReturn(installed); + return trust; + } + + /** + * Returns the process-wide registry singleton, cleared for test isolation. The registry's own + * constructor is package-private; tests here live in a different package, so we use the singleton and + * clear it. Tests run sequentially, so this is safe. + */ + private static BrokerInstallResumeRegistry freshRegistry() { + final BrokerInstallResumeRegistry registry = BrokerInstallResumeRegistry.getInstance(); + registry.clear(); + return registry; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static ParkedRecord parkedRecord(final CommandCallback callback) { + final InteractiveTokenCommandParameters params = InteractiveTokenCommandParameters.builder() + .platformComponents(mock(IPlatformComponents.class)) + .clientId("client-123") + .redirectUri("msauth://com.contoso.app/hash") + .correlationId(UUID.randomUUID().toString()) + .scopes(Collections.singleton("User.Read")) + .build(); + final InteractiveTokenCommand command = mock(InteractiveTokenCommand.class); + when(command.getCallback()).thenReturn(callback); + when(command.getParameters()).thenReturn(params); + when(command.getCorrelationId()).thenReturn(params.getCorrelationId()); + return new ParkedRecord(command, "upn@contoso.com", Long.MAX_VALUE); + } + + @Test + public void onResumeRedirect_flightOff_isNoOp() { + final BrokerInstallResumeManager mgr = BrokerInstallResumeManager.newInstanceForTesting(); + final BrokerInstallResumeRegistry registry = freshRegistry(); + registry.park("cid", parkedRecord(mock(CommandCallback.class))); + + assertFalse(mgr.onResumeRedirect("cid", CP, flights(false), cpTrust(true, true), registry)); + assertEquals("record must remain parked", 1, registry.size()); + } + + @Test + public void onResumeRedirect_untrustedCaller_isRejected() { + final BrokerInstallResumeManager mgr = BrokerInstallResumeManager.newInstanceForTesting(); + final BrokerInstallResumeRegistry registry = freshRegistry(); + registry.park("cid", parkedRecord(mock(CommandCallback.class))); + + assertFalse(mgr.onResumeRedirect("cid", "com.evil.app", flights(true), cpTrust(false, true), registry)); + assertEquals("record must remain parked", 1, registry.size()); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + public void onResumeRedirect_trustedCallerWithMatch_resumesAndDelivers() { + final BrokerInstallResumeManager mgr = BrokerInstallResumeManager.newInstanceForTesting(); + final AtomicBoolean submitted = new AtomicBoolean(false); + final Object token = new Object(); + mgr.registerSilentResumeSubmitter((params, record) -> { + submitted.set(true); + return token; + }); + final CommandCallback callback = mock(CommandCallback.class); + final BrokerInstallResumeRegistry registry = freshRegistry(); + registry.park("cid", parkedRecord(callback)); + + assertTrue(mgr.onResumeRedirect("cid", CP, flights(true), cpTrust(true, true), registry)); + assertTrue(submitted.get()); + verify(callback, times(1)).onTaskCompleted(token); + assertTrue("record must be consumed", registry.isEmpty()); + } + + @Test + public void onResumeRedirect_noParkedMatch_returnsFalse() { + final BrokerInstallResumeManager mgr = BrokerInstallResumeManager.newInstanceForTesting(); + final BrokerInstallResumeRegistry registry = freshRegistry(); + + assertFalse(mgr.onResumeRedirect("missing", CP, flights(true), cpTrust(true, true), registry)); + } + + @Test + public void onResumeRedirect_emptyCid_returnsFalse_withoutTouchingRegistry() { + final BrokerInstallResumeManager mgr = BrokerInstallResumeManager.newInstanceForTesting(); + final BrokerInstallResumeRegistry registry = freshRegistry(); + registry.park("cid", parkedRecord(mock(CommandCallback.class))); + + assertFalse(mgr.onResumeRedirect("", CP, flights(true), cpTrust(true, true), registry)); + assertFalse(mgr.onResumeRedirectByCapability("", flights(true), cpTrust(false, true), registry)); + assertEquals("empty cid must not disturb parked records", 1, registry.size()); + } + + @Test + public void onResumeRedirectByCapability_cpNotInstalled_isRejected() { + final BrokerInstallResumeManager mgr = BrokerInstallResumeManager.newInstanceForTesting(); + final BrokerInstallResumeRegistry registry = freshRegistry(); + registry.park("cid", parkedRecord(mock(CommandCallback.class))); + + assertFalse(mgr.onResumeRedirectByCapability("cid", flights(true), cpTrust(false, false), registry)); + assertEquals("record must remain parked when CP is not yet valid", 1, registry.size()); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + public void onResumeRedirectByCapability_cpInstalledWithMatch_resumes() { + final BrokerInstallResumeManager mgr = BrokerInstallResumeManager.newInstanceForTesting(); + mgr.registerSilentResumeSubmitter((params, record) -> new Object()); + final CommandCallback callback = mock(CommandCallback.class); + final BrokerInstallResumeRegistry registry = freshRegistry(); + registry.park("cid", parkedRecord(callback)); + + assertTrue(mgr.onResumeRedirectByCapability("cid", flights(true), cpTrust(false, true), registry)); + verify(callback, times(1)).onTaskCompleted(any()); + } + + @Test + public void onAppForegrounded_cpNotInstalled_leavesRequestsParked() { + final BrokerInstallResumeManager mgr = BrokerInstallResumeManager.newInstanceForTesting(); + final BrokerInstallResumeRegistry registry = freshRegistry(); + registry.park("cid", parkedRecord(mock(CommandCallback.class))); + + assertEquals(0, mgr.onAppForegrounded(flights(true), cpTrust(false, false), registry)); + assertEquals(1, registry.size()); + } + + @Test + public void onAppForegrounded_flightOffOrEmpty_isNoOp() { + final BrokerInstallResumeManager mgr = BrokerInstallResumeManager.newInstanceForTesting(); + final BrokerInstallResumeRegistry empty = freshRegistry(); + assertEquals(0, mgr.onAppForegrounded(flights(false), cpTrust(true, true), empty)); + assertEquals(0, mgr.onAppForegrounded(flights(true), cpTrust(true, true), empty)); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + public void onAppForegrounded_cpInstalledWithParked_resumesAll() { + final BrokerInstallResumeManager mgr = BrokerInstallResumeManager.newInstanceForTesting(); + mgr.registerSilentResumeSubmitter((params, record) -> new Object()); + final CommandCallback callback = mock(CommandCallback.class); + final BrokerInstallResumeRegistry registry = freshRegistry(); + registry.park("cid", parkedRecord(callback)); + + assertEquals(1, mgr.onAppForegrounded(flights(true), cpTrust(true, true), registry)); + verify(callback, times(1)).onTaskCompleted(any()); + assertTrue(registry.isEmpty()); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + public void resume_withNoSubmitterRegistered_deliversInstallRequiredError() { + // No submitter registered -> the parked sink is resolved with the original install-required error + // (the request must never hang). + final BrokerInstallResumeManager mgr = BrokerInstallResumeManager.newInstanceForTesting(); + final CommandCallback callback = mock(CommandCallback.class); + final BrokerInstallResumeRegistry registry = freshRegistry(); + registry.park("cid", parkedRecord(callback)); + + assertTrue(mgr.onResumeRedirect("cid", CP, flights(true), cpTrust(true, true), registry)); + verify(callback, times(1)).onError(any(BrokerInstallationRequiredException.class)); + verify(callback, never()).onTaskCompleted(any()); + } +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/AuthenticationConstants.java b/common4j/src/main/com/microsoft/identity/common/java/AuthenticationConstants.java index 4c5ca9757e..7314c90775 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/AuthenticationConstants.java +++ b/common4j/src/main/com/microsoft/identity/common/java/AuthenticationConstants.java @@ -497,6 +497,14 @@ public static final class AAD { */ public static final String APP_LINK_KEY = "app_link"; + /** + * Redirect URI parameter key carrying the parked-request correlation id on the MAM + * broker-install resume redirect. Company Portal redirects back to the calling app via + * {@code ?mam_resume=} after reading the install referrer; the presence of + * this parameter is the discriminator for the resume branch. + */ + public static final String MAM_RESUME_KEY = "mam_resume"; + /** * Broker redirect prefix. */ diff --git a/common4j/src/main/com/microsoft/identity/common/java/commands/BrokerInstallResumeCoordinator.java b/common4j/src/main/com/microsoft/identity/common/java/commands/BrokerInstallResumeCoordinator.java new file mode 100644 index 0000000000..8856403261 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/commands/BrokerInstallResumeCoordinator.java @@ -0,0 +1,143 @@ +// 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; + +import com.microsoft.identity.common.java.commands.parameters.InteractiveTokenCommandParameters; +import com.microsoft.identity.common.java.commands.parameters.SilentTokenCommandParameters; +import com.microsoft.identity.common.java.exception.BaseException; +import com.microsoft.identity.common.java.exception.ClientException; +import com.microsoft.identity.common.java.logging.Logger; +import com.microsoft.identity.common.java.opentelemetry.BrokerInstallResumeTelemetryHelper; + +import lombok.NonNull; + +/** + * Orchestrates the resume of a single parked broker-install request end-to-end (PBI-4): it projects the + * parked interactive request into silent parameters (login_hint = WPJ UPN), invokes the platform-supplied + * silent retry (force-fresh broker discovery + silent submit through the freshly installed Company Portal), + * and delivers the outcome to the app's original callback exactly once via + * {@link BrokerInstallResumeEngine}. When a telemetry helper is supplied it stamps the funnel + * (resume_received → retry_success → delivered, or a failure). + *

+ * Why the submit is delegated. Forcing fresh broker discovery + * ({@code getActiveBroker(shouldSkipCache=true)}) and building the controller stack is a + * platform/consumer responsibility (MSAL {@code MSALControllerFactory} / OneAuth {@code BrokerClient}) — + * {@link com.microsoft.identity.common.java.controllers.IControllerFactory} exposes no cache-skip knob, and + * the controllers captured on the parked command were resolved before Company Portal was installed + * (they route local-only). The coordinator therefore owns the common-side spine (projection, funnel, + * single-resolution) and takes an {@link ISilentResumeSubmitter} the consumer implements to run the actual + * fresh-discovery submit. + *

+ * This class is stateless and thread-safe; the single-resolution guarantee is enforced by the engine via + * {@link ParkedRecord#tryResolve()}. + */ +public final class BrokerInstallResumeCoordinator { + + private static final String TAG = BrokerInstallResumeCoordinator.class.getSimpleName(); + + private BrokerInstallResumeCoordinator() { + } + + /** + * Platform-supplied silent retry. Implementations MUST force-fresh broker discovery + * ({@code shouldSkipCache=true}) so the freshly installed Company Portal is picked up, then submit the + * silent request through the broker controller. + */ + public interface ISilentResumeSubmitter { + /** + * @param params the projected silent request parameters (login_hint already set to the WPJ UPN). + * @param record the parked record being resumed (exposes the original command / UPN if needed). + * @return the resumed authentication result to forward to the original callback (never {@code null}). + * @throws BaseException if the silent broker retry fails; the coordinator forwards it to the + * original callback. + */ + @NonNull + Object submitSilent(@NonNull SilentTokenCommandParameters params, @NonNull ParkedRecord record) + throws BaseException; + } + + /** + * Resumes a single parked request. Idempotent with respect to the single-resolution guard: if the + * record was already resolved (by a competing resume, a TTL sweep, or a duplicate redirect) this is a + * no-op returning {@code false}. + * + * @param record the parked record to resume. + * @param submitter the platform silent retry (fresh discovery + silent submit). + * @param telemetry optional funnel helper; may be {@code null}. + * @return {@code true} if this call delivered the outcome (won the single-resolution race); + * {@code false} otherwise. + */ + public static boolean resume(@NonNull final ParkedRecord record, + @NonNull final ISilentResumeSubmitter submitter, + final BrokerInstallResumeTelemetryHelper telemetry) { + if (record.isResolved()) { + Logger.info(TAG + ":resume", "Parked record already resolved; nothing to resume."); + return false; + } + + final InteractiveTokenCommand command = record.getInteractiveTokenCommand(); + if (command == null || !(command.getParameters() instanceof InteractiveTokenCommandParameters)) { + // Defensive: a well-formed parked record always carries interactive params. If it does not we + // must still resolve the sink so the caller never hangs. + final BaseException error = new ClientException( + ClientException.UNKNOWN_ERROR, + "Parked record is missing interactive parameters; cannot resume."); + if (telemetry != null) { + telemetry.onFailed(BrokerInstallResumeTelemetryHelper.STAGE_RESUME_RECEIVED, + "missing_interactive_parameters"); + } + return BrokerInstallResumeEngine.deliverError(record, error); + } + + if (telemetry != null) { + telemetry.onResumeReceived(); + } + + final InteractiveTokenCommandParameters interactive = + (InteractiveTokenCommandParameters) command.getParameters(); + final SilentTokenCommandParameters silent = + BrokerInstallResumeParamsFactory.toSilentParameters(interactive, record.getUpn()); + + try { + final Object result = submitter.submitSilent(silent, record); + if (telemetry != null) { + telemetry.onRetrySuccess(); + } + final boolean delivered = BrokerInstallResumeEngine.deliverSuccess(record, result); + if (delivered && telemetry != null) { + telemetry.onDelivered(); + } + Logger.info(TAG + ":resume", delivered + ? "Resume delivered to original callback." + : "Resume produced a result but the sink was already resolved."); + return delivered; + } catch (final BaseException e) { + Logger.warn(TAG + ":resume", "Silent broker retry failed on resume; delivering error to caller."); + if (telemetry != null) { + telemetry.onFailed(BrokerInstallResumeTelemetryHelper.STAGE_RESUME_RECEIVED, e); + } + return BrokerInstallResumeEngine.deliverError(record, e); + } + } +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/commands/BrokerInstallResumeEngine.java b/common4j/src/main/com/microsoft/identity/common/java/commands/BrokerInstallResumeEngine.java new file mode 100644 index 0000000000..5c27dd4031 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/commands/BrokerInstallResumeEngine.java @@ -0,0 +1,127 @@ +// 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; + +import com.microsoft.identity.common.java.WarningType; +import com.microsoft.identity.common.java.exception.BaseException; +import com.microsoft.identity.common.java.logging.Logger; + +import java.util.List; + +import lombok.NonNull; + +/** + * Delivers the outcome of a parked broker-install request to the app's original callback, and resolves + * expired parked requests so a parked request can never hang (PBI-4). + *

+ * The actual silent broker retry (force-fresh discovery + silent submit through the freshly installed + * broker) is driven by the platform/OneAuth glue, which holds the controller factory; this engine owns + * the common-side guarantees that the parked sink is fired exactly once (via + * {@link ParkedRecord#tryResolve()}) and that TTL-expired parks are resolved with the original + * install-required error. + */ +public final class BrokerInstallResumeEngine { + + private static final String TAG = BrokerInstallResumeEngine.class.getSimpleName(); + + private BrokerInstallResumeEngine() { + } + + /** + * Supplies the terminal error to deliver for an expired parked request (typically the original + * {@code broker_needs_to_be_installed} error the app would have received today). + */ + public interface IExpiredParkedRequestErrorFactory { + @NonNull + BaseException createErrorForExpiredRequest(@NonNull ParkedRecord record); + } + + /** + * Delivers a successful resume result to the parked request's original callback, exactly once. + * + * @param record the parked record. + * @param result the resumed authentication result to forward to the original callback. + * @return {@code true} if this call delivered the result (won the single-resolution race); + * {@code false} if the sink was already resolved. + */ + @SuppressWarnings({WarningType.unchecked_warning, WarningType.rawtype_warning}) + public static boolean deliverSuccess(@NonNull final ParkedRecord record, final Object result) { + if (!record.tryResolve()) { + Logger.warn(TAG + ":deliverSuccess", "Parked request already resolved; ignoring."); + return false; + } + final InteractiveTokenCommand command = record.getInteractiveTokenCommand(); + if (command != null && command.getCallback() != null) { + command.getCallback().onTaskCompleted(result); + } + return true; + } + + /** + * Delivers an error to the parked request's original callback, exactly once. + * + * @param record the parked record. + * @param error the error to forward to the original callback. + * @return {@code true} if this call delivered the error (won the single-resolution race); + * {@code false} if the sink was already resolved. + */ + @SuppressWarnings({WarningType.unchecked_warning, WarningType.rawtype_warning}) + public static boolean deliverError(@NonNull final ParkedRecord record, @NonNull final BaseException error) { + if (!record.tryResolve()) { + Logger.warn(TAG + ":deliverError", "Parked request already resolved; ignoring."); + return false; + } + final InteractiveTokenCommand command = record.getInteractiveTokenCommand(); + if (command != null && command.getCallback() != null) { + command.getCallback().onError(error); + } + return true; + } + + /** + * Sweeps the registry for TTL-expired parked requests and resolves each with the original + * install-required error so the caller never hangs. + * + * @param registry the park registry. + * @param nowEpochMs the current time in epoch millis. + * @param errorFactory supplies the terminal error to deliver per expired request. + * @return the number of expired requests that were resolved by this call. + */ + public static int sweepAndResolveExpired(@NonNull final BrokerInstallResumeRegistry registry, + final long nowEpochMs, + @NonNull final IExpiredParkedRequestErrorFactory errorFactory) { + final List expired = registry.sweepExpired(nowEpochMs); + int resolved = 0; + for (final ParkedRecord record : expired) { + if (deliverError(record, errorFactory.createErrorForExpiredRequest(record))) { + resolved++; + } + } + if (resolved > 0) { + Logger.info(TAG + ":sweepAndResolveExpired", + "Resolved " + resolved + " expired parked request(s) with the original error."); + } + return resolved; + } +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/commands/BrokerInstallResumeParamsFactory.java b/common4j/src/main/com/microsoft/identity/common/java/commands/BrokerInstallResumeParamsFactory.java new file mode 100644 index 0000000000..681ab36e62 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/commands/BrokerInstallResumeParamsFactory.java @@ -0,0 +1,95 @@ +// 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; + +import com.microsoft.identity.common.java.commands.parameters.InteractiveTokenCommandParameters; +import com.microsoft.identity.common.java.commands.parameters.SilentTokenCommandParameters; +import com.microsoft.identity.common.java.util.StringUtil; + +import lombok.NonNull; + +/** + * Projects a parked interactive request's parameters into the silent-request parameters used to replay + * it through the freshly installed broker on resume (PBI-4). + *

+ * Both {@link InteractiveTokenCommandParameters} and {@link SilentTokenCommandParameters} extend + * {@code TokenCommandParameters}, but they are distinct concrete builder types, so the shared fields are + * copied explicitly. The one intentional change is that {@code login_hint} is set to the WPJ username + * (UPN) captured at park time, so the silent broker retry can identify the account without interaction. + */ +public final class BrokerInstallResumeParamsFactory { + + private BrokerInstallResumeParamsFactory() { + } + + /** + * Builds silent-request parameters equivalent to the parked interactive request, with + * {@code login_hint} set to the supplied UPN. + * + * @param interactive the parked interactive request parameters. + * @param upn the WPJ username (UPN) to use as {@code login_hint}; if null/blank, the + * interactive request's existing {@code login_hint} is preserved. + * @return the projected silent-request parameters. + */ + @NonNull + public static SilentTokenCommandParameters toSilentParameters( + @NonNull final InteractiveTokenCommandParameters interactive, + final String upn) { + final String loginHint = StringUtil.isNullOrEmpty(upn) ? interactive.getLoginHint() : upn; + + return SilentTokenCommandParameters.builder() + // CommandParameters (base) + .platformComponents(interactive.getPlatformComponents()) + .oAuth2TokenCache(interactive.getOAuth2TokenCache()) + .isSharedDevice(interactive.isSharedDevice()) + .applicationName(interactive.getApplicationName()) + .applicationVersion(interactive.getApplicationVersion()) + .requiredBrokerProtocolVersion(interactive.getRequiredBrokerProtocolVersion()) + .sdkType(interactive.getSdkType()) + .sdkVersion(interactive.getSdkVersion()) + .clientId(interactive.getClientId()) + .redirectUri(interactive.getRedirectUri()) + .childClientId(interactive.getChildClientId()) + .childRedirectUri(interactive.getChildRedirectUri()) + .powerOptCheckEnabled(interactive.isPowerOptCheckEnabled()) + .callerPackageName(interactive.getCallerPackageName()) + .callerSignature(interactive.getCallerSignature()) + .correlationId(interactive.getCorrelationId()) + .spanContext(interactive.getSpanContext()) + .flightInformation(interactive.getFlightInformation()) + // TokenCommandParameters + .account(interactive.getAccount()) + .scopes(interactive.getScopes()) + .authority(interactive.getAuthority()) + .claimsRequestJson(interactive.getClaimsRequestJson()) + .authenticationScheme(interactive.getAuthenticationScheme()) + .mamEnrollmentId(interactive.getMamEnrollmentId()) + .forceRefresh(interactive.isForceRefresh()) + .loginHint(loginHint) + .domainHint(interactive.getDomainHint()) + .extraOptions(interactive.getExtraOptions()) + .extraTokenBodyParameters(interactive.getExtraTokenBodyParameters()) + .build(); + } +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/commands/BrokerInstallResumeParker.java b/common4j/src/main/com/microsoft/identity/common/java/commands/BrokerInstallResumeParker.java new file mode 100644 index 0000000000..356b82aab0 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/commands/BrokerInstallResumeParker.java @@ -0,0 +1,109 @@ +// 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; + +import com.microsoft.identity.common.java.controllers.CommandResult; +import com.microsoft.identity.common.java.exception.BrokerInstallationRequiredException; +import com.microsoft.identity.common.java.flighting.CommonFlight; +import com.microsoft.identity.common.java.flighting.IFlightsProvider; +import com.microsoft.identity.common.java.logging.Logger; + +import lombok.NonNull; + +/** + * Decision logic for the MAM broker-install request-resume "park" step (PBI-1). + *

+ * Extracted from {@code CommandDispatcher} so the eligibility and callback-suppression rules can be + * unit-tested in isolation. All behavior is gated behind {@link CommonFlight#ENABLE_BROKER_INSTALL_RESUME}; + * with the flight off, both methods are no-ops and the pre-existing terminal-error behavior is unchanged. + */ +public final class BrokerInstallResumeParker { + + private static final String TAG = BrokerInstallResumeParker.class.getSimpleName(); + + private BrokerInstallResumeParker() { + } + + /** + * Parks the request if it is an eligible broker-install interactive request and the flight is on. + *

+ * Eligible when all hold: the flight is on; the command is an {@link InteractiveTokenCommand}; the + * result is {@link CommandResult.ResultStatus#ERROR}; and the error is a + * {@link BrokerInstallationRequiredException} (which is itself only produced when the flight is on). + * + * @param command the just-executed command. + * @param commandResult the command's terminal result. + * @param flightsProvider the flights provider (injected for testability). + * @param registry the park registry (injected for testability). + * @param parkTtlMillis the park time-to-live in millis. + * @param nowEpochMs the current time in epoch millis. + * @return {@code true} if the request was parked (the caller must then suppress the terminal + * callback); {@code false} otherwise. + */ + public static boolean parkIfEligible(@NonNull final BaseCommand command, + @NonNull final CommandResult commandResult, + @NonNull final IFlightsProvider flightsProvider, + @NonNull final BrokerInstallResumeRegistry registry, + final long parkTtlMillis, + final long nowEpochMs) { + if (!flightsProvider.isFlightEnabled(CommonFlight.ENABLE_BROKER_INSTALL_RESUME)) { + return false; + } + if (!(command instanceof InteractiveTokenCommand)) { + return false; + } + if (commandResult.getStatus() != CommandResult.ResultStatus.ERROR + || !(commandResult.getResult() instanceof BrokerInstallationRequiredException)) { + return false; + } + + final BrokerInstallationRequiredException exception = + (BrokerInstallationRequiredException) commandResult.getResult(); + final String correlationId = command.getCorrelationId(); + registry.park( + correlationId, + new ParkedRecord( + (InteractiveTokenCommand) command, + exception.getUsername(), + nowEpochMs + parkTtlMillis)); + Logger.info(TAG + ":parkIfEligible", + "Parked broker-install interactive request; the terminal error will be suppressed " + + "and the request resumed after the broker is installed."); + return true; + } + + /** + * @param correlationId the command's correlation id. + * @param flightsProvider the flights provider (injected for testability). + * @param registry the park registry (injected for testability). + * @return {@code true} if the command's terminal callback must be suppressed because a matching + * parked record is awaiting broker-install resume. + */ + public static boolean isCallbackSuppressed(@NonNull final String correlationId, + @NonNull final IFlightsProvider flightsProvider, + @NonNull final BrokerInstallResumeRegistry registry) { + return flightsProvider.isFlightEnabled(CommonFlight.ENABLE_BROKER_INSTALL_RESUME) + && registry.peek(correlationId) != null; + } +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/commands/BrokerInstallResumeRegistry.java b/common4j/src/main/com/microsoft/identity/common/java/commands/BrokerInstallResumeRegistry.java new file mode 100644 index 0000000000..1ef3c6ea73 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/commands/BrokerInstallResumeRegistry.java @@ -0,0 +1,190 @@ +// 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; + +import com.microsoft.identity.common.java.logging.Logger; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import lombok.NonNull; + +/** + * Process-wide, in-memory registry of interactive token requests that have been parked while the user + * installs the broker (Company Portal) for a MAM Conditional-Access flow. + *

+ * Records are keyed by {@code correlationId} (a UUIDv4). Keying by correlation id lets concurrent + * requests from the same app coexist as distinct records; a resume selects exactly one via its + * {@code mam_resume=} value while leaving the others untouched (they resolve on TTL). Cross-app + * isolation is inherent because each app process has its own registry instance. + *

+ * Lookups via {@link #match(String)} are single-use (the record is removed on match). Storage is + * in-memory only — process death during the install is an accepted loss (the request degrades to the + * app's normal sign-in). This class is thread-safe. + */ +public class BrokerInstallResumeRegistry { + + private static final String TAG = BrokerInstallResumeRegistry.class.getSimpleName(); + + /** + * Default park time-to-live: 7 minutes. Covers a realistic Company Portal download + install + + * first-launch on slow networks while staying bounded. On expiry the parked sink must be resolved + * with the original install-required error so the caller never hangs. + */ + public static final long DEFAULT_PARK_TTL_MILLISECONDS = 7L * 60L * 1000L; + + private static final BrokerInstallResumeRegistry INSTANCE = new BrokerInstallResumeRegistry(); + + private final Map mParkedByCorrelationId = new ConcurrentHashMap<>(); + + /** + * Visible for testing so tests can exercise an isolated registry. Production code should use + * {@link #getInstance()}. + */ + BrokerInstallResumeRegistry() { + } + + /** + * @return the process-wide singleton registry. + */ + public static BrokerInstallResumeRegistry getInstance() { + return INSTANCE; + } + + /** + * Parks a request. Overwrites any existing record for the same correlation id. + * + * @param correlationId the request correlation id (park key). + * @param record the record to park. + */ + public void park(@NonNull final String correlationId, @NonNull final ParkedRecord record) { + mParkedByCorrelationId.put(correlationId, record); + Logger.info(TAG + ":park", "Parked broker-install request. Outstanding parked count: " + + mParkedByCorrelationId.size()); + } + + /** + * Single-use lookup: atomically removes and returns the record for the given correlation id. + * + * @param correlationId the correlation id echoed back by the broker (e.g. {@code mam_resume=}). + * @return the parked record, or {@code null} if none matched (unknown / already-consumed / expired-and-swept). + */ + public ParkedRecord match(@NonNull final String correlationId) { + return mParkedByCorrelationId.remove(correlationId); + } + + /** + * Foreground-fallback lookup for the cid-less resume path (§16 item 13). When Company Portal's + * install redirect does not carry a {@code mam_resume=} (e.g. it only brings the app back to the + * foreground), we resume every still-parked request in this process. Each returned record is atomically + * removed so it is claimed at most once; the caller is responsible for resolving each record's sink. + *

+ * In practice an app has at most one outstanding interactive request, so this typically returns a + * single record; returning the full set keeps the contract correct if multiple were parked. + * + * @return the list of claimed-and-removed parked records (never {@code null}; possibly empty). + */ + @NonNull + public List claimAllPending() { + final List claimed = new ArrayList<>(); + for (final Map.Entry entry : mParkedByCorrelationId.entrySet()) { + // remove(key, value) so we only claim the exact record we observed. + if (mParkedByCorrelationId.remove(entry.getKey(), entry.getValue())) { + claimed.add(entry.getValue()); + } + } + if (!claimed.isEmpty()) { + Logger.info(TAG + ":claimAllPending", "Claimed " + claimed.size() + + " parked request(s) for foreground-fallback resume."); + } + return claimed; + } + + /** + * Non-destructive lookup. + * + * @param correlationId the correlation id. + * @return the parked record without removing it, or {@code null}. + */ + public ParkedRecord peek(@NonNull final String correlationId) { + return mParkedByCorrelationId.get(correlationId); + } + + /** + * Removes the record for the given correlation id, if present. + * + * @param correlationId the correlation id. + * @return the removed record, or {@code null}. + */ + public ParkedRecord remove(@NonNull final String correlationId) { + return mParkedByCorrelationId.remove(correlationId); + } + + /** + * Removes and returns every record that is expired at {@code nowEpochMs}. The caller is responsible + * for resolving each returned record's pending sink (with the original install-required error). + * + * @param nowEpochMs the current time in epoch millis. + * @return the list of expired-and-removed records (never {@code null}). + */ + @NonNull + public List sweepExpired(final long nowEpochMs) { + final List expired = new ArrayList<>(); + for (final Map.Entry entry : mParkedByCorrelationId.entrySet()) { + if (entry.getValue().isExpired(nowEpochMs)) { + // remove(key, value) so we only claim the exact record we observed as expired. + if (mParkedByCorrelationId.remove(entry.getKey(), entry.getValue())) { + expired.add(entry.getValue()); + } + } + } + if (!expired.isEmpty()) { + Logger.info(TAG + ":sweepExpired", "Swept " + expired.size() + " expired parked request(s)."); + } + return expired; + } + + /** + * @return the number of currently parked records. + */ + public int size() { + return mParkedByCorrelationId.size(); + } + + /** + * @return {@code true} if there are no parked records. + */ + public boolean isEmpty() { + return mParkedByCorrelationId.isEmpty(); + } + + /** + * Removes all parked records. Intended for test isolation. + */ + public void clear() { + mParkedByCorrelationId.clear(); + } +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/commands/ParkedRecord.java b/common4j/src/main/com/microsoft/identity/common/java/commands/ParkedRecord.java new file mode 100644 index 0000000000..acdf130aa6 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/commands/ParkedRecord.java @@ -0,0 +1,100 @@ +// 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; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * An in-memory record of an interactive token request that has been "parked" while the user installs + * the broker (Company Portal) for a MAM Conditional-Access flow. + *

+ * The record holds the original in-flight {@link InteractiveTokenCommand} (which owns the request + * parameters, controller factory and the app's {@code CommandCallback}), the WPJ username (UPN) used + * as {@code login_hint} on the silent broker retry, and an expiry timestamp. The {@link #tryResolve()} + * guard ensures the parked request's result sink / callback is fired exactly once across the + * resume-success, resume-failure and TTL-expiry races. + *

+ * Records live only in process memory (see {@link BrokerInstallResumeRegistry}); process death during + * the install is an accepted loss. + */ +public final class ParkedRecord { + + private final InteractiveTokenCommand mInteractiveTokenCommand; + + private final String mUpn; + + private final long mExpiresAtEpochMs; + + private final AtomicBoolean mResolved = new AtomicBoolean(false); + + /** + * @param interactiveTokenCommand the parked in-flight interactive command (may be {@code null} in + * tests that exercise registry semantics only). + * @param upn the WPJ username (UPN) to inject as {@code login_hint} on resume. + * @param expiresAtEpochMs epoch millis after which this record is expired. + */ + public ParkedRecord(final InteractiveTokenCommand interactiveTokenCommand, + final String upn, + final long expiresAtEpochMs) { + this.mInteractiveTokenCommand = interactiveTokenCommand; + this.mUpn = upn; + this.mExpiresAtEpochMs = expiresAtEpochMs; + } + + public InteractiveTokenCommand getInteractiveTokenCommand() { + return mInteractiveTokenCommand; + } + + public String getUpn() { + return mUpn; + } + + public long getExpiresAtEpochMs() { + return mExpiresAtEpochMs; + } + + /** + * @param nowEpochMs the current time in epoch millis. + * @return {@code true} if this record has reached or passed its expiry. + */ + public boolean isExpired(final long nowEpochMs) { + return nowEpochMs >= mExpiresAtEpochMs; + } + + /** + * Atomically claims the right to resolve (fire) the parked sink/callback exactly once. + * + * @return {@code true} for the first caller only; {@code false} on every subsequent call. + */ + public boolean tryResolve() { + return mResolved.compareAndSet(false, true); + } + + /** + * @return {@code true} once the parked sink/callback has been claimed via {@link #tryResolve()}. + */ + public boolean isResolved() { + return mResolved.get(); + } +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/controllers/CommandDispatcher.java b/common4j/src/main/com/microsoft/identity/common/java/controllers/CommandDispatcher.java index d5ff2c1e12..fe9419e769 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/controllers/CommandDispatcher.java +++ b/common4j/src/main/com/microsoft/identity/common/java/controllers/CommandDispatcher.java @@ -43,6 +43,8 @@ import com.microsoft.identity.common.java.BuildConfig; import com.microsoft.identity.common.java.WarningType; import com.microsoft.identity.common.java.commands.BaseCommand; +import com.microsoft.identity.common.java.commands.BrokerInstallResumeParker; +import com.microsoft.identity.common.java.commands.BrokerInstallResumeRegistry; import com.microsoft.identity.common.java.commands.DeviceCodeFlowAuthResultCommand; import com.microsoft.identity.common.java.commands.DeviceCodeFlowCommand; import com.microsoft.identity.common.java.commands.DeviceCodeFlowTokenResultCommand; @@ -873,6 +875,19 @@ private static CommandResult executeCommand(@SuppressWarnings(WarningType.rawtyp // set correlation id on Local Authentication Result setCorrelationIdOnResult(commandResult, correlationId); setTelemetryOnResultAndFlush(commandResult, correlationId); + + // MAM broker-install request resume (flight-gated): if this is an interactive request blocked + // by a Conditional-Access "install broker" response, park it in-memory instead of surfacing the + // terminal error. The terminal callback is then suppressed in returnCommandResult; the request + // is resumed and delivered after the broker is installed. + BrokerInstallResumeParker.parkIfEligible( + command, + commandResult, + CommonFlightsManager.INSTANCE.getFlightsProvider(), + BrokerInstallResumeRegistry.getInstance(), + BrokerInstallResumeRegistry.DEFAULT_PARK_TTL_MILLISECONDS, + System.currentTimeMillis()); + return commandResult; } @@ -907,6 +922,19 @@ private static void returnCommandResult ( @SuppressWarnings(WarningType.rawtype_warning) @NonNull final BaseCommand command, @NonNull final CommandResult result){ + // MAM broker-install request resume (flight-gated): if this request has been parked while + // the user installs the broker, suppress its terminal callback. The pending sink is fired + // later by the resume path (on success) or by the TTL sweep (with the original error). + if (BrokerInstallResumeParker.isCallbackSuppressed( + command.getParameters().getCorrelationId(), + CommonFlightsManager.INSTANCE.getFlightsProvider(), + BrokerInstallResumeRegistry.getInstance())) { + Logger.info(TAG + ":returnCommandResult", + "Interactive request is parked for broker-install resume; suppressing the " + + "terminal callback."); + return; + } + final IPlatformUtil platformUtil = command.getParameters().getPlatformComponents().getPlatformUtil(); platformUtil.onReturnCommandResult(command); platformUtil.postCommandResult(new Runnable() { diff --git a/common4j/src/main/com/microsoft/identity/common/java/controllers/ExceptionAdapter.java b/common4j/src/main/com/microsoft/identity/common/java/controllers/ExceptionAdapter.java index c2cbed900a..8bb1e99218 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/controllers/ExceptionAdapter.java +++ b/common4j/src/main/com/microsoft/identity/common/java/controllers/ExceptionAdapter.java @@ -30,6 +30,7 @@ import com.microsoft.identity.common.java.constants.OAuth2ErrorCode; import com.microsoft.identity.common.java.constants.OAuth2SubErrorCode; import com.microsoft.identity.common.java.exception.BaseException; +import com.microsoft.identity.common.java.exception.BrokerInstallationRequiredException; import com.microsoft.identity.common.java.exception.ClientException; import com.microsoft.identity.common.java.exception.DeviceRegistrationRequiredException; import com.microsoft.identity.common.java.exception.InsufficientDeviceRegistrationException; @@ -161,6 +162,20 @@ private static BaseException getExceptionByAuthorizationResult(@NonNull final Au microsoftAuthorizationErrorResponse.getErrorDescription(), microsoftAuthorizationErrorResponse.getUpnToWpj() ); + } else if (MicrosoftAuthorizationErrorResponse.BROKER_NEEDS_TO_BE_INSTALLED.equals( + microsoftAuthorizationErrorResponse.getError()) + && CommonFlightsManager.INSTANCE.getFlightsProvider() + .isFlightEnabled(CommonFlight.ENABLE_BROKER_INSTALL_RESUME)) { + // MAM broker-install request-resume engine: return a dedicated exception that + // carries the WPJ username (UPN) so the request can be parked and later replayed + // silently through the freshly installed broker. Only produced when the flight is + // on; with the flight off the generic ServiceException below is returned unchanged. + return new BrokerInstallationRequiredException( + microsoftAuthorizationErrorResponse.getError(), + microsoftAuthorizationErrorResponse.getErrorDescription(), + microsoftAuthorizationErrorResponse.getUpnToWpj(), + null /* installLink is not carried on the error response today */ + ); } } diff --git a/common4j/src/main/com/microsoft/identity/common/java/exception/BrokerInstallationRequiredException.java b/common4j/src/main/com/microsoft/identity/common/java/exception/BrokerInstallationRequiredException.java new file mode 100644 index 0000000000..c44ed3c0e0 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/exception/BrokerInstallationRequiredException.java @@ -0,0 +1,82 @@ +// 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.exception; + +import lombok.NonNull; + +/** + * Internal exception representing a Conditional-Access "broker installation required" response + * (error {@code broker_needs_to_be_installed}) during an interactive request. + *

+ * Unlike the generic {@link ServiceException} that the SDK returns today for this case, this + * exception carries the WPJ username (UPN) and the Play Store install link so the MAM broker-install + * request-resume engine can park the request and later replay it silently through the freshly + * installed broker (Company Portal) with {@code login_hint = UPN}. + *

+ * This exception is only produced when {@code CommonFlight.ENABLE_BROKER_INSTALL_RESUME} is on; with + * the flight off, the SDK continues to return the pre-existing {@link ServiceException} unchanged. It + * is internal to the resume path and is not surfaced to the application when the flow is engaged. + */ +public final class BrokerInstallationRequiredException extends BaseException { + + private static final long serialVersionUID = 7401329131099683829L; + + public static final String sName = + "com.microsoft.identity.common.exception.BrokerInstallationRequiredException"; + + /** + * The Play Store install link ({@code app_link}) for the broker, if available. May be {@code null} + * because the value is not currently attached to the authorization error response; the actual + * store launch reads it from the redirect parameters directly. + */ + private final String mInstallLink; + + /** + * @param errorCode the service error code (typically {@code broker_needs_to_be_installed}). + * @param errorDescription the human-readable error description. + * @param userName the WPJ username (UPN) returned by the service; used as {@code login_hint} + * on the resume retry. May be {@code null}. + * @param installLink the broker install {@code app_link}, if known. May be {@code null}. + */ + public BrokerInstallationRequiredException(@NonNull final String errorCode, + @NonNull final String errorDescription, + final String userName, + final String installLink) { + super(errorCode, errorDescription); + super.setUsername(userName); + this.mInstallLink = installLink; + } + + /** + * @return the broker install link, or {@code null} if it was not carried on the error response. + */ + public String getInstallLink() { + return mInstallLink; + } + + @Override + public String getExceptionName() { + return sName; + } +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java index f50ec0e1bd..9d52a6ecfa 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java +++ b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java @@ -304,7 +304,18 @@ public enum CommonFlight implements IFlightConfig { /** * Flight to enable request origin display in the HTTP authentication dialog. */ - ENABLE_HTTP_AUTH_ORIGIN_DISPLAY("EnableHttpAuthOriginDisplay", false); + ENABLE_HTTP_AUTH_ORIGIN_DISPLAY("EnableHttpAuthOriginDisplay", false), + + /** + * Flight to enable the MAM broker-install request-resume engine. When enabled, an interactive + * request blocked by a Conditional-Access "install broker" (Company Portal) response is parked + * in-memory instead of returning the terminal install-required error; after the broker is + * installed the request is replayed silently and the token delivered on the original callback. + *

+ * Default off for safe rollout; ramp / kill-switch via ECS. With the flight off, the pre-existing + * terminal install-required behavior is unchanged. + */ + ENABLE_BROKER_INSTALL_RESUME("EnableBrokerInstallResume", false); private String key; private Object defaultValue; diff --git a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java index ada8d235f7..5fe92b9bb2 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java +++ b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java @@ -767,4 +767,28 @@ public enum AttributeName { server_caller_data_boundary, //endregion + + //region MAM Broker-Install Request Resume + // NOTE: any attribute added here MUST be mirrored in the broker4j AttributeName enum + // (ad-accounts-for-android, .../opentelemetry/AttributeName.java) with an appropriate + // DataClassification (SystemMetadata for these non-PII stage flags/strings) — a separate + // broker-repo change. Do not place UPNs, tokens, or other secrets on these attributes. + + /** Current funnel stage: parked | referrer_fired | resume_received | retry_success | delivered. */ + broker_install_resume_stage, + /** True when the interactive request was parked on the install-required path. */ + broker_install_resume_parked, + /** True when the Play Store launch carrying the install referrer was fired. */ + broker_install_resume_referrer_fired, + /** True when the mam_resume redirect was received and matched a parked request. */ + broker_install_resume_resume_received, + /** True when the silent broker retry succeeded. */ + broker_install_resume_retry_success, + /** True when the token was delivered to the app's original callback. */ + broker_install_resume_delivered, + /** True when a resume redirect arrived but no parked request matched (process-death indicator). */ + broker_install_resume_no_park, + /** Bounded failure reason for the resume funnel; never PII / token. */ + broker_install_resume_failure_reason, + //endregion } diff --git a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/BrokerInstallResumeTelemetryHelper.java b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/BrokerInstallResumeTelemetryHelper.java new file mode 100644 index 0000000000..771b7703b6 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/BrokerInstallResumeTelemetryHelper.java @@ -0,0 +1,152 @@ +// 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.opentelemetry; + +import com.microsoft.identity.common.java.util.StringUtil; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.StatusCode; +import lombok.NonNull; + +/** + * Stateful telemetry helper for the MAM broker-install request-resume funnel (PBI-4). Owns a single + * {@link SpanName#BrokerInstallResume} span across the park -> resume boundary (which spans different + * callbacks and a Play Store round-trip), stamping a boolean per stage plus a {@code broker_install_resume_stage} + * string, and terminating the span exactly once with {@link StatusCode#OK} on delivery or + * {@link StatusCode#ERROR} on failure. + *

+ * Modeled on {@link CertBasedAuthTelemetryHelper}. Emitted only on the flighted resume path. + */ +public class BrokerInstallResumeTelemetryHelper { + + /** Funnel stage values for {@link AttributeName#broker_install_resume_stage}. */ + public static final String STAGE_PARKED = "parked"; + public static final String STAGE_REFERRER_FIRED = "referrer_fired"; + public static final String STAGE_RESUME_RECEIVED = "resume_received"; + public static final String STAGE_RETRY_SUCCESS = "retry_success"; + public static final String STAGE_DELIVERED = "delivered"; + + private final Span mSpan; + + /** + * @param spanContext the parent span context (e.g. the interactive/ATS span). + */ + public BrokerInstallResumeTelemetryHelper(@NonNull final SpanContext spanContext) { + mSpan = OTelUtility.createSpanFromParent(SpanName.BrokerInstallResume.name(), spanContext); + } + + /** + * Use when no parent span context is available. + */ + public BrokerInstallResumeTelemetryHelper() { + mSpan = OTelUtility.createSpan(SpanName.BrokerInstallResume.name()); + } + + /** + * Stamps the correlation id for joinability with the interactive/ATS span. + * + * @param correlationId the request correlation id. + */ + public void setCorrelationId(final String correlationId) { + if (!StringUtil.isNullOrEmpty(correlationId)) { + mSpan.setAttribute(AttributeName.correlation_id.name(), correlationId); + } + } + + /** Stage 1: the interactive request was parked. */ + public void onParked() { + mSpan.setAttribute(AttributeName.broker_install_resume_parked.name(), true); + mSpan.setAttribute(AttributeName.broker_install_resume_stage.name(), STAGE_PARKED); + } + + /** Stage 2: the Play Store launch carrying the install referrer was fired. */ + public void onReferrerFired() { + mSpan.setAttribute(AttributeName.broker_install_resume_referrer_fired.name(), true); + mSpan.setAttribute(AttributeName.broker_install_resume_stage.name(), STAGE_REFERRER_FIRED); + } + + /** Stage 3: the mam_resume redirect was received and matched a parked request. */ + public void onResumeReceived() { + mSpan.setAttribute(AttributeName.broker_install_resume_resume_received.name(), true); + mSpan.setAttribute(AttributeName.broker_install_resume_stage.name(), STAGE_RESUME_RECEIVED); + } + + /** + * A resume redirect arrived but no parked request matched (process-death indicator). Terminates the + * span with an error status. + */ + @SuppressFBWarnings + public void onResumeReceivedNoPark() { + mSpan.setAttribute(AttributeName.broker_install_resume_no_park.name(), true); + mSpan.setStatus(StatusCode.ERROR, "resume received but no parked request matched"); + mSpan.end(); + } + + /** Stage 4: the silent broker retry succeeded. */ + public void onRetrySuccess() { + mSpan.setAttribute(AttributeName.broker_install_resume_retry_success.name(), true); + mSpan.setAttribute(AttributeName.broker_install_resume_stage.name(), STAGE_RETRY_SUCCESS); + } + + /** + * Stage 5: the token was delivered to the app's original callback. Terminates the span with OK. + */ + @SuppressFBWarnings + public void onDelivered() { + mSpan.setAttribute(AttributeName.broker_install_resume_delivered.name(), true); + mSpan.setAttribute(AttributeName.broker_install_resume_stage.name(), STAGE_DELIVERED); + mSpan.setStatus(StatusCode.OK); + mSpan.end(); + } + + /** + * Records a failure at the given funnel stage and terminates the span with an error status. + * + * @param stage the funnel stage at which the failure occurred (one of the STAGE_* constants). + * @param reason a bounded, non-PII failure reason. + */ + @SuppressFBWarnings + public void onFailed(@NonNull final String stage, @NonNull final String reason) { + mSpan.setAttribute(AttributeName.broker_install_resume_stage.name(), stage); + mSpan.setAttribute(AttributeName.broker_install_resume_failure_reason.name(), reason); + mSpan.setStatus(StatusCode.ERROR, reason); + mSpan.end(); + } + + /** + * Records a failure with an exception at the given funnel stage and terminates the span with an + * error status. + * + * @param stage the funnel stage at which the failure occurred. + * @param throwable the exception that caused the failure. + */ + @SuppressFBWarnings + public void onFailed(@NonNull final String stage, @NonNull final Throwable throwable) { + mSpan.setAttribute(AttributeName.broker_install_resume_stage.name(), stage); + mSpan.recordException(throwable); + mSpan.setStatus(StatusCode.ERROR); + mSpan.end(); + } +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/SpanName.java b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/SpanName.java index 32be456c05..fffea7cfb6 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/SpanName.java +++ b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/SpanName.java @@ -128,5 +128,12 @@ public enum SpanName { * when the {@code EnableHttpAuthOriginDisplay} flight is on; used to confirm the flighted path is * executing successfully in dashboards after the flight is ramped. */ - HttpAuthOriginDisplay + HttpAuthOriginDisplay, + + /** + * Span name for the MAM broker-install request-resume funnel (parked -> referrer-fired -> + * resume-received -> retry-success -> delivered). Emitted only when the + * {@code EnableBrokerInstallResume} flight is on. + */ + BrokerInstallResume } diff --git a/common4j/src/main/com/microsoft/identity/common/java/providers/MamInstallReferrerBuilder.java b/common4j/src/main/com/microsoft/identity/common/java/providers/MamInstallReferrerBuilder.java new file mode 100644 index 0000000000..f3818c5224 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/providers/MamInstallReferrerBuilder.java @@ -0,0 +1,234 @@ +// 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.providers; + +import com.microsoft.identity.common.java.logging.Logger; +import com.microsoft.identity.common.java.util.CommonURIBuilder; +import com.microsoft.identity.common.java.util.StringUtil; + +import java.io.UnsupportedEncodingException; +import java.net.URISyntaxException; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Builds the Play Store install referrer for the MAM broker-install request-resume flow, and decorates + * the broker install link with it (PBI-2, Feature AB#3676213). + *

+ * The referrer carries routing data only — {@code src=mamca}, the calling app package, the app's + * redirect URI, and the parked request correlation id — so Company Portal can, on first launch, skip + * its sign-in UX and redirect back to {@code ?mam_resume=}. It carries no UPN or + * secrets (the UPN is out-of-band; see the resume engine). + *

+ * Wire contract (frozen): {@code src=mamca&originPkg=&redirectUri=&cid=}, URL-encoded, + * {@code &}-delimited {@code key=value}; parsed with {@code URLDecoder.decode} then + * {@code Uri.parse("?"+s).getQueryParameter(k)} (the OneAuth {@code AccountTransfer} convention). + *

+ * Two launch forms are supported: the primary decorates the eSTS-provided {@code https://play.google.com} + * {@code app_link} with a single {@code referrer} parameter (kept on the {@code BrokerInstallLinkValidator} + * allow-list); the fallback builds a {@code market://details?id=&referrer=<...>} URI. Both feed the + * same Play Install Referrer API on the installed app. + */ +public final class MamInstallReferrerBuilder { + + private static final String TAG = MamInstallReferrerBuilder.class.getSimpleName(); + + /** Referrer discriminator key. */ + public static final String KEY_SRC = "src"; + /** Calling app package name key. */ + public static final String KEY_ORIGIN_PKG = "originPkg"; + /** App redirect URI key (Company Portal appends {@code ?mam_resume=} to this). */ + public static final String KEY_REDIRECT_URI = "redirectUri"; + /** Parked request correlation id key. */ + public static final String KEY_CID = "cid"; + + /** Locked discriminator value; Company Portal branches on {@code src == mamca}. */ + public static final String SRC_MAM_CA = "mamca"; + + /** The Play install referrer query-parameter name. */ + public static final String REFERRER_QUERY_PARAM = "referrer"; + + private static final String MARKET_DETAILS_PREFIX = "market://details?id="; + private static final String UTF_8 = "UTF-8"; + + private MamInstallReferrerBuilder() { + } + + /** + * Builds the packed referrer value {@code src=mamca&originPkg=..&redirectUri=..&cid=..} with each + * value URL-encoded. + * + * @param originPkg the calling app package name. + * @param redirectUri the app's redirect URI. + * @param cid the parked request correlation id. + * @return the packed, inner-encoded referrer value. + */ + public static String buildReferrerValue(final String originPkg, + final String redirectUri, + final String cid) { + return KEY_SRC + "=" + SRC_MAM_CA + + "&" + KEY_ORIGIN_PKG + "=" + encode(originPkg) + + "&" + KEY_REDIRECT_URI + "=" + encode(redirectUri) + + "&" + KEY_CID + "=" + encode(cid); + } + + /** + * Primary launch form: appends the packed referrer as a single {@code referrer} parameter to the + * eSTS-provided {@code app_link}. Uses {@link CommonURIBuilder} so the outer percent-encoding is + * applied consistently and exactly one {@code referrer} parameter results. + *

+ * Safe by design: if the {@code app_link} or any referrer input is null/blank, or the link cannot be + * parsed, the original {@code app_link} is returned unchanged so the existing install flow is never + * broken. + * + * @param appLink the server-provided Play Store install link. + * @param originPkg the calling app package name. + * @param redirectUri the app's redirect URI. + * @param cid the parked request correlation id. + * @return the decorated link, or the original {@code app_link} if decoration is not possible. + */ + public static String decorateAppLinkWithReferrer(final String appLink, + final String originPkg, + final String redirectUri, + final String cid) { + if (StringUtil.isNullOrEmpty(appLink) + || StringUtil.isNullOrEmpty(originPkg) + || StringUtil.isNullOrEmpty(redirectUri) + || StringUtil.isNullOrEmpty(cid)) { + return appLink; + } + try { + return new CommonURIBuilder(appLink) + .setParameter(REFERRER_QUERY_PARAM, buildReferrerValue(originPkg, redirectUri, cid)) + .build() + .toString(); + } catch (final URISyntaxException e) { + Logger.warn(TAG + ":decorateAppLinkWithReferrer", + "Could not parse app_link to append the install referrer; launching it unchanged."); + return appLink; + } + } + + /** + * Fallback launch form: builds {@code market://details?id=&referrer=} for use + * when appending to the {@code app_link} does not reliably reach Play. + * + * @param playStoreId the broker Play Store package id (e.g. Company Portal). + * @param originPkg the calling app package name. + * @param redirectUri the app's redirect URI. + * @param cid the parked request correlation id. + * @return the {@code market://} install URI, or {@code null} if the package id is null/blank. + */ + public static String buildMarketFallbackUri(final String playStoreId, + final String originPkg, + final String redirectUri, + final String cid) { + if (StringUtil.isNullOrEmpty(playStoreId)) { + return null; + } + return MARKET_DETAILS_PREFIX + playStoreId + + "&" + REFERRER_QUERY_PARAM + "=" + encode(buildReferrerValue(originPkg, redirectUri, cid)); + } + + /** + * CP-compatible launch form (confirmed by the Company Portal team, Veena Soman, 2026-07-17): appends a + * single bare {@code referrer=} to the eSTS-provided {@code app_link}, matching the + * {@code &referrer=} pattern Company Portal already supports today. This is the form + * used at the production launch site: it lets Company Portal identify — and redirect back to — the + * calling app, which (combined with the in-process park registry + foreground-fallback resume) is + * sufficient to resume without Company Portal having to round-trip the correlation id. The richer + * {@link #decorateAppLinkWithReferrer} form is reserved for the automatic {@code mam_resume=} path + * once Company Portal confirms it passes the full referrer value through to first launch. + *

+ * Safe by design: if the {@code app_link} or {@code originPkg} is null/blank, or the link cannot be + * parsed, the original {@code app_link} is returned unchanged so the existing install flow is never + * broken. + * + * @param appLink the server-provided Play Store install link. + * @param originPkg the calling app package name. + * @return the decorated link, or the original {@code app_link} if decoration is not possible. + */ + public static String decorateAppLinkWithOriginReferrer(final String appLink, final String originPkg) { + if (StringUtil.isNullOrEmpty(appLink) || StringUtil.isNullOrEmpty(originPkg)) { + return appLink; + } + try { + return new CommonURIBuilder(appLink) + .setParameter(REFERRER_QUERY_PARAM, originPkg) + .build() + .toString(); + } catch (final URISyntaxException e) { + Logger.warn(TAG + ":decorateAppLinkWithOriginReferrer", + "Could not parse app_link to append the install referrer; launching it unchanged."); + return appLink; + } + } + + /** + * Parses a packed referrer value back into its key/value pairs. This mirrors the parse Company + * Portal performs on first launch (blueprint: OneAuth {@code AccountTransfer}). + * + * @param referrer the packed referrer value (as delivered by the Play Install Referrer API). + * @return an ordered map of the decoded key/value pairs (never {@code null}). + */ + public static Map parseReferrer(final String referrer) { + final Map out = new LinkedHashMap<>(); + if (StringUtil.isNullOrEmpty(referrer)) { + return out; + } + for (final String pair : referrer.split("&")) { + final int idx = pair.indexOf('='); + if (idx <= 0) { + continue; + } + out.put(pair.substring(0, idx), decode(pair.substring(idx + 1))); + } + return out; + } + + private static String encode(final String value) { + if (value == null) { + return ""; + } + try { + return URLEncoder.encode(value, UTF_8); + } catch (final UnsupportedEncodingException e) { + // UTF-8 is always supported; return the raw value defensively. + return value; + } + } + + private static String decode(final String value) { + if (value == null) { + return ""; + } + try { + return URLDecoder.decode(value, UTF_8); + } catch (final UnsupportedEncodingException e) { + return value; + } + } +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/providers/RawAuthorizationResult.java b/common4j/src/main/com/microsoft/identity/common/java/providers/RawAuthorizationResult.java index f117bfefd8..52ba837c7e 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/providers/RawAuthorizationResult.java +++ b/common4j/src/main/com/microsoft/identity/common/java/providers/RawAuthorizationResult.java @@ -24,6 +24,7 @@ import static com.microsoft.identity.common.java.AuthenticationConstants.AAD.APP_LINK_KEY; import static com.microsoft.identity.common.java.AuthenticationConstants.AAD.DEVICE_REGISTRATION_REDIRECT_URI_HOSTNAME; +import static com.microsoft.identity.common.java.AuthenticationConstants.AAD.MAM_RESUME_KEY; import static com.microsoft.identity.common.java.AuthenticationConstants.AAD.REDIRECT_PREFIX; import static com.microsoft.identity.common.java.AuthenticationConstants.AAD.UPGRADE_DEVICE_REGISTRATION_REDIRECT_URI_HOSTNAME; import static com.microsoft.identity.common.java.AuthenticationConstants.Browser.RESPONSE_EXCEPTION; @@ -35,6 +36,8 @@ import com.microsoft.identity.common.java.controllers.ExceptionAdapter; import com.microsoft.identity.common.java.exception.BaseException; import com.microsoft.identity.common.java.exception.ClientException; +import com.microsoft.identity.common.java.flighting.CommonFlight; +import com.microsoft.identity.common.java.flighting.CommonFlightsManager; import com.microsoft.identity.common.java.logging.Logger; import com.microsoft.identity.common.java.util.StringUtil; import com.microsoft.identity.common.java.util.UrlUtil; @@ -121,7 +124,14 @@ public enum ResultCode { /** * This is used to indicate that the authorization was cancelled due to timeout. */ - TIMED_OUT(2011); + TIMED_OUT(2011), + + /** + * MAM broker-install request resume: Company Portal redirected back to the calling app with a + * {@code mam_resume=} parameter after the broker was installed. The parked interactive + * request identified by {@code cid} should be resumed silently through the broker. + */ + BROKER_INSTALL_RESUME(2012); private final int mCode; @@ -210,6 +220,18 @@ public static PropertyBag toPropertyBag(@NonNull final RawAuthorizationResult da return propertyBag; } + /** + * @return the parked-request correlation id carried on a {@link ResultCode#BROKER_INSTALL_RESUME} + * redirect (the {@code mam_resume} parameter value), or {@code null} if the final redirect + * is absent or carries no {@code mam_resume} parameter. + */ + public String getMamResumeCorrelationId() { + if (mAuthorizationFinalUri == null) { + return null; + } + return UrlUtil.getParameters(mAuthorizationFinalUri).get(MAM_RESUME_KEY); + } + @NonNull public static RawAuthorizationResult fromPropertyBag(@NonNull final PropertyBag propertyBag) { return RawAuthorizationResult.builder() @@ -224,6 +246,17 @@ private static ResultCode getResultCodeFromFinalRedirectUri(@NonNull final URI u final Map parameters = UrlUtil.getParameters(uri); if (REDIRECT_PREFIX.equalsIgnoreCase(uri.getScheme())) { + // MAM broker-install resume: Company Portal redirects back to the calling app with + // msauth:///?mam_resume= after the broker is installed. The presence of the + // mam_resume parameter is the discriminator; it takes precedence over other classifications. + // Flight-gated so that with the flight off the redirect is classified exactly as before. + if (parameters.containsKey(MAM_RESUME_KEY) + && CommonFlightsManager.INSTANCE.getFlightsProvider() + .isFlightEnabled(CommonFlight.ENABLE_BROKER_INSTALL_RESUME)) { + Logger.info(methodTag, "Detected MAM broker-install resume redirect (mam_resume)."); + return ResultCode.BROKER_INSTALL_RESUME; + } + // i.e. (Browser) msauth://com.msft.identity.client.sample.local/1wIqXSqBj7w%2Bh11ZifsnqwgyKrY%3D?wpj=1&username=idlab1%40msidlab4.onmicrosoft.com&app_link=https%3a%2f%2fplay.google.com%2fstore%2fapps%2fdetails%3fid%3dcom.azure.authenticator // (WebView) msauth://wpj/?username=idlab1%40msidlab4.onmicrosoft.com&app_link=https%3a%2f%2fplay.google.com%2fstore%2fapps%2fdetails%3fid%3dcom.azure.authenticator%26referrer%3dcom.msft.identity.client.sample.local if (parameters.containsKey(APP_LINK_KEY)) { diff --git a/common4j/src/test/com/microsoft/identity/common/java/commands/BrokerInstallResumeCoordinatorTest.java b/common4j/src/test/com/microsoft/identity/common/java/commands/BrokerInstallResumeCoordinatorTest.java new file mode 100644 index 0000000000..6d2d1e705f --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/commands/BrokerInstallResumeCoordinatorTest.java @@ -0,0 +1,154 @@ +// 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; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.microsoft.identity.common.java.commands.parameters.InteractiveTokenCommandParameters; +import com.microsoft.identity.common.java.commands.parameters.SilentTokenCommandParameters; +import com.microsoft.identity.common.java.exception.BaseException; +import com.microsoft.identity.common.java.exception.ClientException; +import com.microsoft.identity.common.java.exception.ServiceException; +import com.microsoft.identity.common.java.interfaces.IPlatformComponents; + +import org.junit.Test; + +import java.util.Collections; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Unit tests for {@link BrokerInstallResumeCoordinator} — the resume orchestration spine (PBI-4): project + * parked interactive params to silent (login_hint = UPN), invoke the platform submitter, and deliver the + * outcome to the original callback exactly once. + */ +public class BrokerInstallResumeCoordinatorTest { + + private static final String UPN = "upn@contoso.com"; + + private static InteractiveTokenCommandParameters interactiveParams() { + return InteractiveTokenCommandParameters.builder() + .platformComponents(mock(IPlatformComponents.class)) + .clientId("client-123") + .redirectUri("msauth://com.contoso.app/hash") + .correlationId("cid-abc") + .scopes(Collections.singleton("User.Read")) + .loginHint("old-hint@contoso.com") + .build(); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static ParkedRecord recordWithParams(final CommandCallback callback, + final InteractiveTokenCommandParameters params) { + final InteractiveTokenCommand command = mock(InteractiveTokenCommand.class); + when(command.getCallback()).thenReturn(callback); + when(command.getParameters()).thenReturn(params); + return new ParkedRecord(command, UPN, Long.MAX_VALUE); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + public void resume_success_projectsSilentParamsWithUpn_andDeliversResult() { + final CommandCallback callback = mock(CommandCallback.class); + final ParkedRecord record = recordWithParams(callback, interactiveParams()); + final Object token = new Object(); + final AtomicReference captured = new AtomicReference<>(); + + final boolean delivered = BrokerInstallResumeCoordinator.resume(record, (params, rec) -> { + captured.set(params); + return token; + }, null); + + assertTrue(delivered); + assertTrue(record.isResolved()); + verify(callback, times(1)).onTaskCompleted(eq(token)); + verify(callback, never()).onError(any()); + assertEquals("login_hint must be the UPN on the silent retry", UPN, captured.get().getLoginHint()); + assertEquals("client-123", captured.get().getClientId()); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + public void resume_whenSubmitterThrows_deliversErrorToOriginalCallback() { + final CommandCallback callback = mock(CommandCallback.class); + final ParkedRecord record = recordWithParams(callback, interactiveParams()); + final BaseException failure = new ServiceException("invalid_grant", "no token", null); + + final boolean delivered = BrokerInstallResumeCoordinator.resume(record, (params, rec) -> { + throw failure; + }, null); + + assertTrue(delivered); + verify(callback, times(1)).onError(eq(failure)); + verify(callback, never()).onTaskCompleted(any()); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + public void resume_whenAlreadyResolved_isNoOp_andDoesNotSubmit() { + final CommandCallback callback = mock(CommandCallback.class); + final ParkedRecord record = recordWithParams(callback, interactiveParams()); + assertTrue("pre-resolve", record.tryResolve()); + final AtomicReference submitted = new AtomicReference<>(false); + + final boolean delivered = BrokerInstallResumeCoordinator.resume(record, (params, rec) -> { + submitted.set(true); + return new Object(); + }, null); + + assertFalse(delivered); + assertFalse("submitter must not run for an already-resolved record", submitted.get()); + verify(callback, never()).onTaskCompleted(any()); + verify(callback, never()).onError(any()); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + public void resume_missingInteractiveParams_deliversClientErrorWithoutSubmitting() { + final CommandCallback callback = mock(CommandCallback.class); + final InteractiveTokenCommand command = mock(InteractiveTokenCommand.class); + when(command.getCallback()).thenReturn(callback); + // Not an InteractiveTokenCommandParameters -> triggers the defensive guard. + when(command.getParameters()).thenReturn(mock(SilentTokenCommandParameters.class)); + final ParkedRecord record = new ParkedRecord(command, UPN, Long.MAX_VALUE); + final AtomicReference submitted = new AtomicReference<>(false); + + final boolean delivered = BrokerInstallResumeCoordinator.resume(record, (params, rec) -> { + submitted.set(true); + return new Object(); + }, null); + + assertTrue(delivered); + assertFalse(submitted.get()); + verify(callback, times(1)).onError(any(ClientException.class)); + } +} diff --git a/common4j/src/test/com/microsoft/identity/common/java/commands/BrokerInstallResumeEngineTest.java b/common4j/src/test/com/microsoft/identity/common/java/commands/BrokerInstallResumeEngineTest.java new file mode 100644 index 0000000000..01124b7889 --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/commands/BrokerInstallResumeEngineTest.java @@ -0,0 +1,142 @@ +// 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; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.microsoft.identity.common.java.exception.BaseException; +import com.microsoft.identity.common.java.exception.ServiceException; + +import org.junit.Test; + +import java.util.UUID; + +/** + * Unit tests for {@link BrokerInstallResumeEngine} — single-resolution sink delivery and TTL-expiry + * resolution (PBI-4). Guarantees the parked callback fires exactly once and that expired parks are + * resolved with the original error (never hang). + */ +public class BrokerInstallResumeEngineTest { + + private static final BrokerInstallResumeEngine.IExpiredParkedRequestErrorFactory ERROR_FACTORY = + record -> new ServiceException("broker_needs_to_be_installed", + "Device needs to have broker installed", null); + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static ParkedRecord recordWithCallback(final CommandCallback callback, final long expiresAt) { + final InteractiveTokenCommand command = mock(InteractiveTokenCommand.class); + when(command.getCallback()).thenReturn(callback); + return new ParkedRecord(command, "upn@contoso.com", expiresAt); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + public void deliverSuccess_firesOnTaskCompletedExactlyOnce() { + final CommandCallback callback = mock(CommandCallback.class); + final ParkedRecord record = recordWithCallback(callback, Long.MAX_VALUE); + final Object result = new Object(); + + assertTrue(BrokerInstallResumeEngine.deliverSuccess(record, result)); + assertFalse("second delivery is a no-op", BrokerInstallResumeEngine.deliverSuccess(record, new Object())); + + verify(callback, times(1)).onTaskCompleted(eq(result)); + verify(callback, never()).onError(any()); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + public void deliverError_firesOnErrorExactlyOnce() { + final CommandCallback callback = mock(CommandCallback.class); + final ParkedRecord record = recordWithCallback(callback, Long.MAX_VALUE); + final BaseException error = new ServiceException("e", "d", null); + + assertTrue(BrokerInstallResumeEngine.deliverError(record, error)); + assertFalse(BrokerInstallResumeEngine.deliverError(record, new ServiceException("e2", "d2", null))); + + verify(callback, times(1)).onError(eq(error)); + verify(callback, never()).onTaskCompleted(any()); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + public void afterSuccess_errorDeliveryIsSuppressed() { + final CommandCallback callback = mock(CommandCallback.class); + final ParkedRecord record = recordWithCallback(callback, Long.MAX_VALUE); + + assertTrue(BrokerInstallResumeEngine.deliverSuccess(record, "token")); + assertFalse("cannot error after success", BrokerInstallResumeEngine.deliverError(record, new ServiceException("e", "d", null))); + + verify(callback, times(1)).onTaskCompleted(any()); + verify(callback, never()).onError(any()); + } + + @Test + public void deliverSuccess_withNullCommand_resolvesWithoutThrowing() { + final ParkedRecord record = new ParkedRecord(null, "upn", Long.MAX_VALUE); + assertTrue(BrokerInstallResumeEngine.deliverSuccess(record, "token")); + assertTrue(record.isResolved()); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + public void sweepAndResolveExpired_resolvesOnlyExpired_withOriginalError() { + final BrokerInstallResumeRegistry registry = new BrokerInstallResumeRegistry(); + final long now = 100_000L; + + final CommandCallback expiredCallback = mock(CommandCallback.class); + final CommandCallback liveCallback = mock(CommandCallback.class); + final String expiredCid = UUID.randomUUID().toString(); + final String liveCid = UUID.randomUUID().toString(); + registry.park(expiredCid, recordWithCallback(expiredCallback, now - 1)); + registry.park(liveCid, recordWithCallback(liveCallback, now + 60_000L)); + + final int resolved = BrokerInstallResumeEngine.sweepAndResolveExpired(registry, now, ERROR_FACTORY); + + assertEquals(1, resolved); + verify(expiredCallback, times(1)).onError(any(ServiceException.class)); + verify(liveCallback, never()).onError(any()); + assertNull("expired record removed", registry.peek(expiredCid)); + assertTrue("live record untouched", registry.peek(liveCid) != null); + } + + @Test + public void sweepAndResolveExpired_nothingExpired_returnsZero() { + final BrokerInstallResumeRegistry registry = new BrokerInstallResumeRegistry(); + registry.park(UUID.randomUUID().toString(), + recordWithCallback(mock(CommandCallback.class), Long.MAX_VALUE)); + + assertEquals(0, BrokerInstallResumeEngine.sweepAndResolveExpired( + registry, System.currentTimeMillis(), ERROR_FACTORY)); + } +} diff --git a/common4j/src/test/com/microsoft/identity/common/java/commands/BrokerInstallResumeParamsFactoryTest.java b/common4j/src/test/com/microsoft/identity/common/java/commands/BrokerInstallResumeParamsFactoryTest.java new file mode 100644 index 0000000000..469cc3afae --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/commands/BrokerInstallResumeParamsFactoryTest.java @@ -0,0 +1,97 @@ +// 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; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +import com.microsoft.identity.common.java.commands.parameters.InteractiveTokenCommandParameters; +import com.microsoft.identity.common.java.commands.parameters.SilentTokenCommandParameters; +import com.microsoft.identity.common.java.interfaces.IPlatformComponents; + +import org.junit.Test; + +import java.util.Collections; +import java.util.Set; + +/** + * Unit tests for {@link BrokerInstallResumeParamsFactory} — the interactive->silent parameter + * projection used to replay a parked request through the broker with {@code login_hint = UPN} (PBI-4). + */ +public class BrokerInstallResumeParamsFactoryTest { + + private static final String UPN = "upn@contoso.com"; + + private static InteractiveTokenCommandParameters interactiveParams(final String loginHint) { + final Set scopes = Collections.singleton("User.Read"); + return InteractiveTokenCommandParameters.builder() + .platformComponents(mock(IPlatformComponents.class)) + .clientId("client-123") + .redirectUri("msauth://com.contoso.app/hash") + .correlationId("cid-abc") + .applicationName("Outlook") + .applicationVersion("4.0") + .forceRefresh(true) + .scopes(scopes) + .loginHint(loginHint) + .build(); + } + + @Test + public void projectsSharedFields_andSetsLoginHintToUpn() { + final InteractiveTokenCommandParameters interactive = interactiveParams("old-hint@contoso.com"); + + final SilentTokenCommandParameters silent = + BrokerInstallResumeParamsFactory.toSilentParameters(interactive, UPN); + + assertEquals("client-123", silent.getClientId()); + assertEquals("msauth://com.contoso.app/hash", silent.getRedirectUri()); + assertEquals("cid-abc", silent.getCorrelationId()); + assertEquals("Outlook", silent.getApplicationName()); + assertEquals("4.0", silent.getApplicationVersion()); + assertTrue(silent.isForceRefresh()); + assertTrue(silent.getScopes().contains("User.Read")); + assertSame(interactive.getPlatformComponents(), silent.getPlatformComponents()); + assertEquals("login_hint must be set to the UPN", UPN, silent.getLoginHint()); + } + + @Test + public void preservesExistingLoginHint_whenUpnIsNullOrBlank() { + final InteractiveTokenCommandParameters interactive = interactiveParams("old-hint@contoso.com"); + + assertEquals("old-hint@contoso.com", + BrokerInstallResumeParamsFactory.toSilentParameters(interactive, null).getLoginHint()); + assertEquals("old-hint@contoso.com", + BrokerInstallResumeParamsFactory.toSilentParameters(interactive, " ").getLoginHint()); + } + + @Test + public void producesASilentTokenCommandParameters() { + final SilentTokenCommandParameters silent = + BrokerInstallResumeParamsFactory.toSilentParameters(interactiveParams(null), UPN); + assertTrue(silent instanceof SilentTokenCommandParameters); + } +} diff --git a/common4j/src/test/com/microsoft/identity/common/java/commands/BrokerInstallResumeParkerTest.java b/common4j/src/test/com/microsoft/identity/common/java/commands/BrokerInstallResumeParkerTest.java new file mode 100644 index 0000000000..89db3979f4 --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/commands/BrokerInstallResumeParkerTest.java @@ -0,0 +1,180 @@ +// 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; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.microsoft.identity.common.java.controllers.CommandResult; +import com.microsoft.identity.common.java.exception.BrokerInstallationRequiredException; +import com.microsoft.identity.common.java.exception.ServiceException; +import com.microsoft.identity.common.java.flighting.CommonFlight; +import com.microsoft.identity.common.java.flighting.MockFlightsProvider; + +import org.junit.Test; + +import java.util.UUID; + +/** + * Unit tests for {@link BrokerInstallResumeParker} — the flight-gated decision logic for parking a + * broker-install interactive request and suppressing its terminal callback (PBI-1). + */ +public class BrokerInstallResumeParkerTest { + + private static final long TTL = BrokerInstallResumeRegistry.DEFAULT_PARK_TTL_MILLISECONDS; + private static final long NOW = 1_000_000L; + private static final String UPN = "idlab1@msidlab4.onmicrosoft.com"; + + private static MockFlightsProvider flights(final boolean enabled) { + final MockFlightsProvider provider = new MockFlightsProvider(); + provider.addFlight(CommonFlight.ENABLE_BROKER_INSTALL_RESUME.getKey(), String.valueOf(enabled)); + return provider; + } + + private static InteractiveTokenCommand interactiveCommand(final String cid) { + final InteractiveTokenCommand command = mock(InteractiveTokenCommand.class); + when(command.getCorrelationId()).thenReturn(cid); + return command; + } + + private static CommandResult brokerInstallErrorResult(final String cid) { + return new CommandResult<>( + CommandResult.ResultStatus.ERROR, + new BrokerInstallationRequiredException("broker_needs_to_be_installed", + "Device needs to have broker installed", UPN, null), + cid); + } + + // region parkIfEligible + + @Test + public void parkIfEligible_eligible_parksAndReturnsTrue() { + final BrokerInstallResumeRegistry registry = new BrokerInstallResumeRegistry(); + final String cid = UUID.randomUUID().toString(); + final InteractiveTokenCommand command = interactiveCommand(cid); + + final boolean parked = BrokerInstallResumeParker.parkIfEligible( + command, brokerInstallErrorResult(cid), flights(true), registry, TTL, NOW); + + assertTrue(parked); + final ParkedRecord record = registry.peek(cid); + assertNotNull("the request must be parked", record); + assertEquals(UPN, record.getUpn()); + assertSame(command, record.getInteractiveTokenCommand()); + assertEquals(NOW + TTL, record.getExpiresAtEpochMs()); + } + + @Test + public void parkIfEligible_flightOff_returnsFalse_andDoesNotPark() { + final BrokerInstallResumeRegistry registry = new BrokerInstallResumeRegistry(); + final String cid = UUID.randomUUID().toString(); + + final boolean parked = BrokerInstallResumeParker.parkIfEligible( + interactiveCommand(cid), brokerInstallErrorResult(cid), flights(false), registry, TTL, NOW); + + assertFalse(parked); + assertTrue(registry.isEmpty()); + } + + @Test + public void parkIfEligible_nonInteractiveCommand_returnsFalse() { + final BrokerInstallResumeRegistry registry = new BrokerInstallResumeRegistry(); + final String cid = UUID.randomUUID().toString(); + final SilentTokenCommand silent = mock(SilentTokenCommand.class); + when(silent.getCorrelationId()).thenReturn(cid); + + final boolean parked = BrokerInstallResumeParker.parkIfEligible( + silent, brokerInstallErrorResult(cid), flights(true), registry, TTL, NOW); + + assertFalse("silent commands must never be parked", parked); + assertTrue(registry.isEmpty()); + } + + @Test + public void parkIfEligible_errorButNotBrokerInstall_returnsFalse() { + final BrokerInstallResumeRegistry registry = new BrokerInstallResumeRegistry(); + final String cid = UUID.randomUUID().toString(); + final CommandResult plainServiceError = new CommandResult<>( + CommandResult.ResultStatus.ERROR, + new ServiceException("some_other_error", "desc", null), + cid); + + final boolean parked = BrokerInstallResumeParker.parkIfEligible( + interactiveCommand(cid), plainServiceError, flights(true), registry, TTL, NOW); + + assertFalse(parked); + assertTrue(registry.isEmpty()); + } + + @Test + public void parkIfEligible_completedResult_returnsFalse() { + final BrokerInstallResumeRegistry registry = new BrokerInstallResumeRegistry(); + final String cid = UUID.randomUUID().toString(); + final CommandResult completed = new CommandResult<>( + CommandResult.ResultStatus.COMPLETED, "token", cid); + + final boolean parked = BrokerInstallResumeParker.parkIfEligible( + interactiveCommand(cid), completed, flights(true), registry, TTL, NOW); + + assertFalse(parked); + assertTrue(registry.isEmpty()); + } + + // endregion + + // region isCallbackSuppressed + + @Test + public void isCallbackSuppressed_parkedAndFlightOn_true() { + final BrokerInstallResumeRegistry registry = new BrokerInstallResumeRegistry(); + final String cid = UUID.randomUUID().toString(); + registry.park(cid, new ParkedRecord(null, UPN, Long.MAX_VALUE)); + + assertTrue(BrokerInstallResumeParker.isCallbackSuppressed(cid, flights(true), registry)); + } + + @Test + public void isCallbackSuppressed_notParked_false() { + final BrokerInstallResumeRegistry registry = new BrokerInstallResumeRegistry(); + assertFalse(BrokerInstallResumeParker.isCallbackSuppressed( + UUID.randomUUID().toString(), flights(true), registry)); + } + + @Test + public void isCallbackSuppressed_flightOff_false_evenIfParked() { + final BrokerInstallResumeRegistry registry = new BrokerInstallResumeRegistry(); + final String cid = UUID.randomUUID().toString(); + registry.park(cid, new ParkedRecord(null, UPN, Long.MAX_VALUE)); + + assertFalse(BrokerInstallResumeParker.isCallbackSuppressed(cid, flights(false), registry)); + } + + // endregion +} diff --git a/common4j/src/test/com/microsoft/identity/common/java/commands/BrokerInstallResumeRegistryTest.java b/common4j/src/test/com/microsoft/identity/common/java/commands/BrokerInstallResumeRegistryTest.java new file mode 100644 index 0000000000..d152a983bd --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/commands/BrokerInstallResumeRegistryTest.java @@ -0,0 +1,235 @@ +// 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; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Unit tests for {@link BrokerInstallResumeRegistry} and {@link ParkedRecord} — the in-memory park + * store for the MAM broker-install request-resume engine (PBI-1 foundation). + */ +public class BrokerInstallResumeRegistryTest { + + private static ParkedRecord record(final String upn, final long expiresAtEpochMs) { + return new ParkedRecord(null /* command not needed for registry semantics */, upn, expiresAtEpochMs); + } + + // region ParkedRecord + + @Test + public void parkedRecord_isExpired_atOrAfterExpiry() { + final ParkedRecord record = record("u@contoso.com", 1_000L); + assertFalse(record.isExpired(999L)); + assertTrue("expiry is inclusive", record.isExpired(1_000L)); + assertTrue(record.isExpired(1_001L)); + } + + @Test + public void parkedRecord_tryResolve_succeedsExactlyOnce() { + final ParkedRecord record = record("u@contoso.com", Long.MAX_VALUE); + assertFalse(record.isResolved()); + assertTrue("first resolve wins", record.tryResolve()); + assertFalse("second resolve loses", record.tryResolve()); + assertFalse(record.tryResolve()); + assertTrue(record.isResolved()); + } + + @Test + public void parkedRecord_tryResolve_isThreadSafe_onlyOneWinner() throws Exception { + final ParkedRecord record = record("u@contoso.com", Long.MAX_VALUE); + final int threads = 32; + final ExecutorService pool = Executors.newFixedThreadPool(threads); + final AtomicInteger winners = new AtomicInteger(0); + try { + final java.util.concurrent.CountDownLatch start = new java.util.concurrent.CountDownLatch(1); + final java.util.concurrent.CountDownLatch done = new java.util.concurrent.CountDownLatch(threads); + for (int i = 0; i < threads; i++) { + pool.submit(() -> { + try { + start.await(); + if (record.tryResolve()) { + winners.incrementAndGet(); + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + }); + } + start.countDown(); + assertTrue(done.await(10, TimeUnit.SECONDS)); + } finally { + pool.shutdownNow(); + } + assertEquals("exactly one thread may resolve the sink", 1, winners.get()); + } + + // endregion + + // region Registry + + @Test + public void park_thenPeek_returnsSameRecord_withoutRemoving() { + final BrokerInstallResumeRegistry registry = new BrokerInstallResumeRegistry(); + final String cid = UUID.randomUUID().toString(); + final ParkedRecord parked = record("u@contoso.com", Long.MAX_VALUE); + + registry.park(cid, parked); + + assertSame(parked, registry.peek(cid)); + assertEquals(1, registry.size()); + assertFalse(registry.isEmpty()); + } + + @Test + public void match_isSingleUse_secondMatchReturnsNull() { + final BrokerInstallResumeRegistry registry = new BrokerInstallResumeRegistry(); + final String cid = UUID.randomUUID().toString(); + final ParkedRecord parked = record("u@contoso.com", Long.MAX_VALUE); + registry.park(cid, parked); + + assertSame(parked, registry.match(cid)); + assertNull("match consumes the record", registry.match(cid)); + assertTrue(registry.isEmpty()); + } + + @Test + public void match_unknownCid_returnsNull_isBenignNoOp() { + final BrokerInstallResumeRegistry registry = new BrokerInstallResumeRegistry(); + assertNull(registry.match(UUID.randomUUID().toString())); + } + + @Test + public void concurrentDistinctCids_coexist_andDoNotCollide() { + final BrokerInstallResumeRegistry registry = new BrokerInstallResumeRegistry(); + final int count = 200; + final String[] cids = new String[count]; + for (int i = 0; i < count; i++) { + cids[i] = UUID.randomUUID().toString(); + } + + java.util.stream.IntStream.range(0, count).parallel().forEach(i -> + registry.park(cids[i], record("u" + i + "@contoso.com", Long.MAX_VALUE))); + + assertEquals(count, registry.size()); + // Each cid resolves to its own distinct record (no collision / overwrite). + for (int i = 0; i < count; i++) { + final ParkedRecord matched = registry.match(cids[i]); + assertNotNull(matched); + assertEquals("u" + i + "@contoso.com", matched.getUpn()); + } + assertTrue(registry.isEmpty()); + } + + @Test + public void sweepExpired_removesOnlyExpired_andReturnsThem() { + final BrokerInstallResumeRegistry registry = new BrokerInstallResumeRegistry(); + final long now = 10_000L; + + final String expiredCid = UUID.randomUUID().toString(); + final String liveCid = UUID.randomUUID().toString(); + final ParkedRecord expired = record("expired@contoso.com", now - 1); // already past + final ParkedRecord live = record("live@contoso.com", now + 60_000L); // still valid + registry.park(expiredCid, expired); + registry.park(liveCid, live); + + final List swept = registry.sweepExpired(now); + + assertEquals(1, swept.size()); + assertSame(expired, swept.get(0)); + assertNull("expired record was removed", registry.peek(expiredCid)); + assertSame("live record is untouched", live, registry.peek(liveCid)); + } + + @Test + public void sweepExpired_whenNothingExpired_returnsEmpty() { + final BrokerInstallResumeRegistry registry = new BrokerInstallResumeRegistry(); + registry.park(UUID.randomUUID().toString(), record("u@contoso.com", Long.MAX_VALUE)); + assertTrue(registry.sweepExpired(System.currentTimeMillis()).isEmpty()); + assertEquals(1, registry.size()); + } + + @Test + public void clear_removesAll() { + final BrokerInstallResumeRegistry registry = new BrokerInstallResumeRegistry(); + registry.park(UUID.randomUUID().toString(), record("u@contoso.com", Long.MAX_VALUE)); + registry.park(UUID.randomUUID().toString(), record("v@contoso.com", Long.MAX_VALUE)); + registry.clear(); + assertTrue(registry.isEmpty()); + } + + @Test + public void defaultTtl_isSevenMinutes() { + assertEquals(7L * 60L * 1000L, BrokerInstallResumeRegistry.DEFAULT_PARK_TTL_MILLISECONDS); + } + + @Test + public void getInstance_returnsProcessSingleton() { + assertSame(BrokerInstallResumeRegistry.getInstance(), BrokerInstallResumeRegistry.getInstance()); + } + + @Test + public void claimAllPending_removesAndReturnsEveryRecord() { + final BrokerInstallResumeRegistry registry = new BrokerInstallResumeRegistry(); + registry.park(UUID.randomUUID().toString(), record("a@contoso.com", Long.MAX_VALUE)); + registry.park(UUID.randomUUID().toString(), record("b@contoso.com", Long.MAX_VALUE)); + + final List claimed = registry.claimAllPending(); + + assertEquals(2, claimed.size()); + assertTrue("all records are claimed and removed", registry.isEmpty()); + } + + @Test + public void claimAllPending_isSingleUse_secondCallReturnsEmpty() { + final BrokerInstallResumeRegistry registry = new BrokerInstallResumeRegistry(); + registry.park(UUID.randomUUID().toString(), record("a@contoso.com", Long.MAX_VALUE)); + + assertEquals(1, registry.claimAllPending().size()); + assertTrue("second claim finds nothing", registry.claimAllPending().isEmpty()); + } + + @Test + public void claimAllPending_whenEmpty_returnsEmptyList() { + final BrokerInstallResumeRegistry registry = new BrokerInstallResumeRegistry(); + assertTrue(registry.claimAllPending().isEmpty()); + } + + // endregion +} diff --git a/common4j/src/test/com/microsoft/identity/common/java/controllers/ExceptionAdapterTests.java b/common4j/src/test/com/microsoft/identity/common/java/controllers/ExceptionAdapterTests.java index dcec4d73c0..95555c6a36 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/controllers/ExceptionAdapterTests.java +++ b/common4j/src/test/com/microsoft/identity/common/java/controllers/ExceptionAdapterTests.java @@ -36,6 +36,7 @@ import com.microsoft.identity.common.java.constants.OAuth2ErrorCode; import com.microsoft.identity.common.java.constants.OAuth2SubErrorCode; import com.microsoft.identity.common.java.exception.BaseException; +import com.microsoft.identity.common.java.exception.BrokerInstallationRequiredException; import com.microsoft.identity.common.java.exception.ClientException; import com.microsoft.identity.common.java.exception.IntuneAppProtectionPolicyRequiredException; import com.microsoft.identity.common.java.exception.ServiceException; @@ -47,6 +48,7 @@ import com.microsoft.identity.common.java.flighting.MockFlightsProvider; import com.microsoft.identity.common.java.providers.microsoft.microsoftsts.MicrosoftStsAuthorizationErrorResponse; import com.microsoft.identity.common.java.providers.microsoft.microsoftsts.MicrosoftStsAuthorizationResult; +import com.microsoft.identity.common.java.providers.microsoft.MicrosoftAuthorizationErrorResponse; import com.microsoft.identity.common.java.providers.microsoft.MicrosoftTokenErrorResponse; import com.microsoft.identity.common.java.providers.oauth2.AuthorizationStatus; import com.microsoft.identity.common.java.providers.oauth2.TokenErrorResponse; @@ -85,6 +87,57 @@ private static void setServerClientDataTelemetryFlight(final boolean enabled) { CommonFlightsManager.INSTANCE.initializeCommonFlightsManager(flightsManager); } + private static void setBrokerInstallResumeFlight(final boolean enabled) { + final MockFlightsProvider flightsProvider = new MockFlightsProvider(); + flightsProvider.addFlight(CommonFlight.ENABLE_BROKER_INSTALL_RESUME.getKey(), String.valueOf(enabled)); + + final MockFlightsManager flightsManager = new MockFlightsManager(); + flightsManager.setMockBrokerFlightsProvider(flightsProvider); + + CommonFlightsManager.INSTANCE.initializeCommonFlightsManager(flightsManager); + } + + private static MicrosoftStsAuthorizationResult brokerInstallRequiredResult(final String upn) { + final MicrosoftStsAuthorizationErrorResponse errorResponse = new MicrosoftStsAuthorizationErrorResponse( + MicrosoftAuthorizationErrorResponse.BROKER_NEEDS_TO_BE_INSTALLED, + MicrosoftAuthorizationErrorResponse.BROKER_NEEDS_TO_BE_INSTALLED_ERROR_DESCRIPTION); + errorResponse.setUpnToWpj(upn); + + final MicrosoftStsAuthorizationResult authResult = mock(MicrosoftStsAuthorizationResult.class); + when(authResult.getAuthorizationStatus()).thenReturn(AuthorizationStatus.FAIL); + when(authResult.getAuthorizationErrorResponse()).thenReturn(errorResponse); + return authResult; + } + + @Test + public void testBrokerInstallRequired_flightOn_returnsBrokerInstallationRequiredException_withUpn() { + setBrokerInstallResumeFlight(true); + final String upn = "idlab1@msidlab4.onmicrosoft.com"; + + final BaseException exception = ExceptionAdapter.exceptionFromAuthorizationResult( + brokerInstallRequiredResult(upn), null); + + assertTrue("Expected BrokerInstallationRequiredException when the flight is on", + exception instanceof BrokerInstallationRequiredException); + assertEquals(MicrosoftAuthorizationErrorResponse.BROKER_NEEDS_TO_BE_INSTALLED, exception.getErrorCode()); + assertEquals("UPN must be carried on the exception for login_hint on resume", + upn, exception.getUsername()); + } + + @Test + public void testBrokerInstallRequired_flightOff_returnsPlainServiceException_unchanged() { + setBrokerInstallResumeFlight(false); + + final BaseException exception = ExceptionAdapter.exceptionFromAuthorizationResult( + brokerInstallRequiredResult("idlab1@msidlab4.onmicrosoft.com"), null); + + assertFalse("Must NOT be the resume exception when the flight is off", + exception instanceof BrokerInstallationRequiredException); + assertTrue("Existing terminal behavior: a plain ServiceException", + exception instanceof ServiceException); + assertEquals(MicrosoftAuthorizationErrorResponse.BROKER_NEEDS_TO_BE_INSTALLED, exception.getErrorCode()); + } + @Test public void testBaseExceptionFromException_TerminalException() throws Exception{ final TerminalException t = new TerminalException("errorMsg", ClientException.KEY_RING_WRITE_FAILURE); diff --git a/common4j/src/test/com/microsoft/identity/common/java/exception/BrokerInstallationRequiredExceptionTest.java b/common4j/src/test/com/microsoft/identity/common/java/exception/BrokerInstallationRequiredExceptionTest.java new file mode 100644 index 0000000000..61d087fbe8 --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/exception/BrokerInstallationRequiredExceptionTest.java @@ -0,0 +1,80 @@ +// 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.exception; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** + * Unit tests for {@link BrokerInstallationRequiredException} — carries the WPJ username (UPN) and the + * install link for the MAM broker-install request-resume engine. + */ +public class BrokerInstallationRequiredExceptionTest { + + private static final String ERROR = "broker_needs_to_be_installed"; + private static final String DESCRIPTION = "Device needs to have broker installed"; + private static final String UPN = "idlab1@msidlab4.onmicrosoft.com"; + private static final String INSTALL_LINK = + "https://play.google.com/store/apps/details?id=com.microsoft.windowsintune.companyportal"; + + @Test + public void carriesErrorCodeDescriptionUpnAndInstallLink() { + final BrokerInstallationRequiredException ex = + new BrokerInstallationRequiredException(ERROR, DESCRIPTION, UPN, INSTALL_LINK); + + assertEquals(ERROR, ex.getErrorCode()); + assertEquals(DESCRIPTION, ex.getMessage()); + assertEquals("UPN is carried via BaseException.username for use as login_hint on resume", + UPN, ex.getUsername()); + assertEquals(INSTALL_LINK, ex.getInstallLink()); + } + + @Test + public void isABaseException() { + final BaseException ex = + new BrokerInstallationRequiredException(ERROR, DESCRIPTION, UPN, INSTALL_LINK); + assertTrue(ex instanceof BrokerInstallationRequiredException); + } + + @Test + public void allowsNullUpnAndInstallLink() { + final BrokerInstallationRequiredException ex = + new BrokerInstallationRequiredException(ERROR, DESCRIPTION, null, null); + assertNull(ex.getUsername()); + assertNull(ex.getInstallLink()); + assertEquals(ERROR, ex.getErrorCode()); + } + + @Test + public void exceptionName_isStableForSerialization() { + final BrokerInstallationRequiredException ex = + new BrokerInstallationRequiredException(ERROR, DESCRIPTION, UPN, INSTALL_LINK); + assertEquals(BrokerInstallationRequiredException.sName, ex.getExceptionName()); + assertEquals("com.microsoft.identity.common.exception.BrokerInstallationRequiredException", + ex.getExceptionName()); + } +} diff --git a/common4j/src/test/com/microsoft/identity/common/java/opentelemetry/BrokerInstallResumeTelemetryHelperTest.java b/common4j/src/test/com/microsoft/identity/common/java/opentelemetry/BrokerInstallResumeTelemetryHelperTest.java new file mode 100644 index 0000000000..f30305b1f8 --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/opentelemetry/BrokerInstallResumeTelemetryHelperTest.java @@ -0,0 +1,74 @@ +// 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.opentelemetry; + +import org.junit.Test; + +/** + * Smoke tests for {@link BrokerInstallResumeTelemetryHelper} (PBI-4). Verifies the funnel lifecycle + * methods run against the default (no-op) OpenTelemetry span without throwing, on both the success and + * failure paths. Per repo guidance we do not assert individual attribute attachment. + */ +public class BrokerInstallResumeTelemetryHelperTest { + + @Test + public void successFunnel_runsWithoutThrowing() { + final BrokerInstallResumeTelemetryHelper helper = new BrokerInstallResumeTelemetryHelper(); + helper.setCorrelationId("3f2504e0-4f89-11d3-9a0c-0305e82c3301"); + helper.onParked(); + helper.onReferrerFired(); + helper.onResumeReceived(); + helper.onRetrySuccess(); + helper.onDelivered(); + } + + @Test + public void failureWithReason_runsWithoutThrowing() { + final BrokerInstallResumeTelemetryHelper helper = new BrokerInstallResumeTelemetryHelper(); + helper.onParked(); + helper.onFailed(BrokerInstallResumeTelemetryHelper.STAGE_RETRY_SUCCESS, "silent_retry_timeout"); + } + + @Test + public void failureWithException_runsWithoutThrowing() { + final BrokerInstallResumeTelemetryHelper helper = new BrokerInstallResumeTelemetryHelper(); + helper.onParked(); + helper.onFailed(BrokerInstallResumeTelemetryHelper.STAGE_RESUME_RECEIVED, + new IllegalStateException("boom")); + } + + @Test + public void resumeReceivedNoPark_runsWithoutThrowing() { + final BrokerInstallResumeTelemetryHelper helper = new BrokerInstallResumeTelemetryHelper(); + helper.onResumeReceivedNoPark(); + } + + @Test + public void setCorrelationId_nullOrEmpty_isNoOp() { + final BrokerInstallResumeTelemetryHelper helper = new BrokerInstallResumeTelemetryHelper(); + helper.setCorrelationId(null); + helper.setCorrelationId(""); + helper.onDelivered(); + } +} diff --git a/common4j/src/test/com/microsoft/identity/common/java/providers/MamInstallReferrerBuilderTest.java b/common4j/src/test/com/microsoft/identity/common/java/providers/MamInstallReferrerBuilderTest.java new file mode 100644 index 0000000000..9846512939 --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/providers/MamInstallReferrerBuilderTest.java @@ -0,0 +1,192 @@ +// 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.providers; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import com.microsoft.identity.common.java.util.CommonURIBuilder; + +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import org.apache.hc.core5.http.NameValuePair; + +/** + * Unit tests for {@link MamInstallReferrerBuilder} (PBI-2). These graduate the earlier proof-of-concept + * into production coverage: they build the packed referrer, prove it keeps the Company Portal Play link + * on the real {@link BrokerInstallLinkValidator} allow-list, prove the encode/decode round-trip recovers + * a base64 redirect URI byte-for-byte, and prove the decoration is null-safe (never breaks the existing + * install launch). + */ +public class MamInstallReferrerBuilderTest { + + private static final String CP_ID = "com.microsoft.windowsintune.companyportal"; + private static final String CP_APP_LINK = "https://play.google.com/store/apps/details?id=" + CP_ID; + private static final String ORIGIN_PKG = "com.microsoft.office.outlook"; + // Realistic msauth redirect: base64 SHA-1 signature contains the tricky '+', '/', '=' characters. + private static final String REDIRECT_URI = "msauth://com.microsoft.office.outlook/GC+pJ8k9dItg3F1lZ7q2rY0aBcD="; + private static final String CID = "3f2504e0-4f89-11d3-9a0c-0305e82c3301"; + + // region buildReferrerValue + + @Test + public void buildReferrerValue_startsWithSrcMamCa_andIsBoundedLength() { + final String referrer = MamInstallReferrerBuilder.buildReferrerValue(ORIGIN_PKG, REDIRECT_URI, CID); + assertTrue("src=mamca must be the discriminator", referrer.startsWith("src=mamca")); + assertTrue(referrer.contains("originPkg=")); + assertTrue(referrer.contains("redirectUri=")); + assertTrue(referrer.contains("cid=" + CID)); + assertTrue("referrer must be comfortably under Play's practical limit", + referrer.length() < 1024); + } + + // endregion + + // region decorateAppLinkWithReferrer + + @Test + public void decorate_keepsCompanyPortalLinkOnAllowlist() { + final String decorated = MamInstallReferrerBuilder.decorateAppLinkWithReferrer( + CP_APP_LINK, ORIGIN_PKG, REDIRECT_URI, CID); + + assertTrue("decorated app_link must remain allow-listed:\n" + decorated, + BrokerInstallLinkValidator.isSafeBrokerInstallLink(decorated)); + } + + @Test + public void decorate_addsExactlyOneReferrerParam() throws Exception { + final String decorated = MamInstallReferrerBuilder.decorateAppLinkWithReferrer( + CP_APP_LINK, ORIGIN_PKG, REDIRECT_URI, CID); + + int referrerCount = 0; + final List params = new CommonURIBuilder(decorated).getQueryParams(); + for (final NameValuePair p : params) { + if (MamInstallReferrerBuilder.REFERRER_QUERY_PARAM.equals(p.getName())) { + referrerCount++; + } + } + assertEquals("exactly one referrer parameter", 1, referrerCount); + } + + @Test + public void decorate_returnsOriginalUnchanged_whenAnyInputMissing() { + assertEquals(CP_APP_LINK, + MamInstallReferrerBuilder.decorateAppLinkWithReferrer(CP_APP_LINK, null, REDIRECT_URI, CID)); + assertEquals(CP_APP_LINK, + MamInstallReferrerBuilder.decorateAppLinkWithReferrer(CP_APP_LINK, ORIGIN_PKG, "", CID)); + assertEquals(CP_APP_LINK, + MamInstallReferrerBuilder.decorateAppLinkWithReferrer(CP_APP_LINK, ORIGIN_PKG, REDIRECT_URI, null)); + assertNull(MamInstallReferrerBuilder.decorateAppLinkWithReferrer(null, ORIGIN_PKG, REDIRECT_URI, CID)); + } + + // endregion + + // region round-trip (simulated Play delivery -> Company Portal parse) + + @Test + public void referrer_roundTrips_recoveringBase64RedirectByteForByte() throws Exception { + final String decorated = MamInstallReferrerBuilder.decorateAppLinkWithReferrer( + CP_APP_LINK, ORIGIN_PKG, REDIRECT_URI, CID); + + // The value Google Play hands to the installed app is the referrer param value, decoded once. + String deliveredReferrer = null; + for (final NameValuePair p : new CommonURIBuilder(decorated).getQueryParams()) { + if (MamInstallReferrerBuilder.REFERRER_QUERY_PARAM.equals(p.getName())) { + deliveredReferrer = p.getValue(); + } + } + + final Map parsed = MamInstallReferrerBuilder.parseReferrer(deliveredReferrer); + assertEquals(MamInstallReferrerBuilder.SRC_MAM_CA, parsed.get(MamInstallReferrerBuilder.KEY_SRC)); + assertEquals(ORIGIN_PKG, parsed.get(MamInstallReferrerBuilder.KEY_ORIGIN_PKG)); + assertEquals(CID, parsed.get(MamInstallReferrerBuilder.KEY_CID)); + assertEquals("base64 redirect (with + / =) must survive the round-trip", + REDIRECT_URI, parsed.get(MamInstallReferrerBuilder.KEY_REDIRECT_URI)); + } + + @Test + public void parseReferrer_emptyOrNull_returnsEmptyMap() { + assertTrue(MamInstallReferrerBuilder.parseReferrer(null).isEmpty()); + assertTrue(MamInstallReferrerBuilder.parseReferrer("").isEmpty()); + } + + // endregion + + // region market:// fallback + + @Test + public void marketFallback_carriesReferrer_targetsBroker_andIsNotHttps() { + final String market = MamInstallReferrerBuilder.buildMarketFallbackUri(CP_ID, ORIGIN_PKG, REDIRECT_URI, CID); + + assertTrue(market.startsWith("market://details?id=" + CP_ID)); + assertTrue(market.contains("referrer=")); + // Not https, so it must be launched directly and never fed to the https-only allow-list validator. + assertFalse(BrokerInstallLinkValidator.isSafeBrokerInstallLink(market)); + } + + @Test + public void marketFallback_nullPackageId_returnsNull() { + assertNull(MamInstallReferrerBuilder.buildMarketFallbackUri(null, ORIGIN_PKG, REDIRECT_URI, CID)); + } + + // endregion + + // region decorateAppLinkWithOriginReferrer (CP-compatible bare-origin form) + + @Test + public void originReferrer_appendsBarePackage_andStaysAllowlisted() throws Exception { + final String decorated = + MamInstallReferrerBuilder.decorateAppLinkWithOriginReferrer(CP_APP_LINK, ORIGIN_PKG); + + assertTrue("decorated app_link must remain allow-listed:\n" + decorated, + BrokerInstallLinkValidator.isSafeBrokerInstallLink(decorated)); + + String deliveredReferrer = null; + int referrerCount = 0; + for (final NameValuePair p : new CommonURIBuilder(decorated).getQueryParams()) { + if (MamInstallReferrerBuilder.REFERRER_QUERY_PARAM.equals(p.getName())) { + deliveredReferrer = p.getValue(); + referrerCount++; + } + } + assertEquals("exactly one referrer parameter", 1, referrerCount); + assertEquals("referrer value is the bare origin package", ORIGIN_PKG, deliveredReferrer); + } + + @Test + public void originReferrer_returnsOriginalUnchanged_whenAnyInputMissing() { + assertEquals(CP_APP_LINK, + MamInstallReferrerBuilder.decorateAppLinkWithOriginReferrer(CP_APP_LINK, null)); + assertEquals(CP_APP_LINK, + MamInstallReferrerBuilder.decorateAppLinkWithOriginReferrer(CP_APP_LINK, "")); + assertNull(MamInstallReferrerBuilder.decorateAppLinkWithOriginReferrer(null, ORIGIN_PKG)); + } + + // endregion +} diff --git a/common4j/src/test/com/microsoft/identity/common/java/providers/RawAuthorizationResultMamResumeTest.java b/common4j/src/test/com/microsoft/identity/common/java/providers/RawAuthorizationResultMamResumeTest.java new file mode 100644 index 0000000000..a63d43be5e --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/providers/RawAuthorizationResultMamResumeTest.java @@ -0,0 +1,143 @@ +// 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.providers; + +import static com.microsoft.identity.common.java.providers.RawAuthorizationResult.ResultCode.BROKER_INSTALLATION_TRIGGERED; +import static com.microsoft.identity.common.java.providers.RawAuthorizationResult.ResultCode.BROKER_INSTALL_RESUME; +import static com.microsoft.identity.common.java.providers.RawAuthorizationResult.ResultCode.COMPLETED; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; + +import com.microsoft.identity.common.java.flighting.CommonFlight; +import com.microsoft.identity.common.java.flighting.CommonFlightsManager; +import com.microsoft.identity.common.java.flighting.MockFlightsManager; +import com.microsoft.identity.common.java.flighting.MockFlightsProvider; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Unit tests for the MAM broker-install resume detection in {@link RawAuthorizationResult} (PBI-3): + * classifying a {@code mam_resume=} redirect and extracting the parked correlation id. The + * classification is flight-gated, so the tests enable {@link CommonFlight#ENABLE_BROKER_INSTALL_RESUME}. + */ +public class RawAuthorizationResultMamResumeTest { + + private static final String CID = "3f2504e0-4f89-11d3-9a0c-0305e82c3301"; + private static final String APP_LINK_ENCODED = + "https%3a%2f%2fplay.google.com%2fstore%2fapps%2fdetails%3fid%3dcom.microsoft.windowsintune.companyportal"; + + @Before + public void setUp() { + setBrokerInstallResumeFlight(true); + } + + @After + public void tearDown() { + CommonFlightsManager.INSTANCE.resetFlightsManager(); + } + + private static void setBrokerInstallResumeFlight(final boolean enabled) { + final MockFlightsProvider flightsProvider = new MockFlightsProvider(); + flightsProvider.addFlight(CommonFlight.ENABLE_BROKER_INSTALL_RESUME.getKey(), String.valueOf(enabled)); + final MockFlightsManager flightsManager = new MockFlightsManager(); + flightsManager.setMockBrokerFlightsProvider(flightsProvider); + CommonFlightsManager.INSTANCE.initializeCommonFlightsManager(flightsManager); + } + + @Test + public void mamResumeRedirect_isClassifiedAsBrokerInstallResume() { + final String redirect = "msauth://com.contoso.app/signaturehash?mam_resume=" + CID; + + final RawAuthorizationResult result = RawAuthorizationResult.fromRedirectUri(redirect); + + assertEquals(BROKER_INSTALL_RESUME, result.getResultCode()); + } + + @Test + public void getMamResumeCorrelationId_returnsTheCid() { + final String redirect = "msauth://com.contoso.app/signaturehash?mam_resume=" + CID; + + final RawAuthorizationResult result = RawAuthorizationResult.fromRedirectUri(redirect); + + assertEquals(CID, result.getMamResumeCorrelationId()); + } + + @Test + public void mamResume_takesPrecedenceOverAppLink() { + // Defensive: if a redirect ever carried both, the resume branch wins. + final String redirect = "msauth://com.contoso.app/signaturehash?mam_resume=" + CID + + "&app_link=" + APP_LINK_ENCODED; + + final RawAuthorizationResult result = RawAuthorizationResult.fromRedirectUri(redirect); + + assertEquals(BROKER_INSTALL_RESUME, result.getResultCode()); + assertEquals(CID, result.getMamResumeCorrelationId()); + } + + @Test + public void installRequiredRedirect_withoutMamResume_stillClassifiedAsInstallTriggered() { + // Regression: the pre-existing broker-install-required classification is unchanged. + final String redirect = "msauth://com.contoso.app/signaturehash?username=idlab1%40msidlab4.onmicrosoft.com" + + "&app_link=" + APP_LINK_ENCODED; + + final RawAuthorizationResult result = RawAuthorizationResult.fromRedirectUri(redirect); + + assertEquals(BROKER_INSTALLATION_TRIGGERED, result.getResultCode()); + assertNull("a non-resume redirect has no mam_resume cid", result.getMamResumeCorrelationId()); + } + + @Test + public void mamResumeParam_onNonMsauthScheme_isNotClassifiedAsResume() { + // The discriminator is scoped to the msauth redirect scheme. + final String redirect = "https://contoso.com/auth?mam_resume=" + CID; + + final RawAuthorizationResult result = RawAuthorizationResult.fromRedirectUri(redirect); + + assertEquals(COMPLETED, result.getResultCode()); + } + + @Test + public void completedRedirect_hasNoMamResumeCid() { + final RawAuthorizationResult result = + RawAuthorizationResult.fromRedirectUri("msauth://com.contoso.app/signaturehash?code=abc"); + + assertEquals(COMPLETED, result.getResultCode()); + assertNull(result.getMamResumeCorrelationId()); + } + + @Test + public void mamResumeRedirect_withFlightOff_isNotClassifiedAsResume() { + // With the flight off, a mam_resume-bearing redirect must be classified exactly as before the + // feature existed (never routed into the resume branch). + setBrokerInstallResumeFlight(false); + final String redirect = "msauth://com.contoso.app/signaturehash?mam_resume=" + CID; + + final RawAuthorizationResult result = RawAuthorizationResult.fromRedirectUri(redirect); + + assertNotEquals(BROKER_INSTALL_RESUME, result.getResultCode()); + } +}