diff --git a/testapps/testapp/src/main/AndroidManifest.xml b/testapps/testapp/src/main/AndroidManifest.xml index 28286ca02..db1f36cb2 100644 --- a/testapps/testapp/src/main/AndroidManifest.xml +++ b/testapps/testapp/src/main/AndroidManifest.xml @@ -87,6 +87,15 @@ android:path="/1wIqXSqBj7w+h11ZifsnqwgyKrY="/> + + + + + + + diff --git a/testapps/testapp/src/main/java/com/microsoft/identity/client/testapp/SilentAuthReceiver.java b/testapps/testapp/src/main/java/com/microsoft/identity/client/testapp/SilentAuthReceiver.java new file mode 100644 index 000000000..5de898dcd --- /dev/null +++ b/testapps/testapp/src/main/java/com/microsoft/identity/client/testapp/SilentAuthReceiver.java @@ -0,0 +1,213 @@ +// 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.client.testapp; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import com.microsoft.identity.client.AcquireTokenSilentParameters; +import com.microsoft.identity.client.AuthenticationCallback; +import com.microsoft.identity.client.IAccount; +import com.microsoft.identity.client.IAuthenticationResult; +import com.microsoft.identity.client.IMultipleAccountPublicClientApplication; +import com.microsoft.identity.client.IPublicClientApplication; +import com.microsoft.identity.client.ISingleAccountPublicClientApplication; +import com.microsoft.identity.client.PublicClientApplication; +import com.microsoft.identity.client.exception.MsalException; +import com.microsoft.identity.common.java.util.StringUtil; + +import java.util.ArrayList; +import java.util.List; + +/** + * BroadcastReceiver that triggers acquireTokenSilent from a background context. + * + * This runs at PROCESS_STATE_RECEIVER priority, which does NOT elevate the + * Broker process enough to get a dozable-allow firewall rule. This simulates + * the production scenario where Outlook handles an FCM push in the background + * and calls the Broker via OneAuth. + * + * Usage: + * adb shell am broadcast \ + * -a com.microsoft.identity.client.testapp.SILENT_AUTH \ + * -n com.msft.identity.client.sample.local/com.microsoft.identity.client.testapp.SilentAuthReceiver \ + * --es scopes "https://graph.microsoft.com/.default" + */ +public class SilentAuthReceiver extends BroadcastReceiver { + + private static final String TAG = "SilentAuthReceiver"; + private static final String DEFAULT_SCOPE = "https://graph.microsoft.com/.default"; + public static final String ACTION_SILENT_AUTH = + "com.microsoft.identity.client.testapp.SILENT_AUTH"; + + @Override + public void onReceive(final Context context, final Intent intent) { + Log.i(TAG, "=== SilentAuthReceiver triggered (PROCESS_STATE_RECEIVER) ==="); + + final String scopes = intent.getStringExtra("scopes"); + final String scopeString = (scopes == null || scopes.trim().isEmpty()) + ? DEFAULT_SCOPE + : scopes.trim(); + + Log.i(TAG, "Scopes: " + scopeString); + + // Use goAsync() to extend the receiver's lifecycle beyond the 10s limit + final PendingResult pendingResult = goAsync(); + + // Create PCA with default config (uses broker) + final int configResourceId = Constants.getResourceIdFromConfigFile(Constants.ConfigFile.DEFAULT); + + PublicClientApplication.create(context.getApplicationContext(), + configResourceId, + new PublicClientApplication.ApplicationCreatedListener() { + @Override + public void onCreated(final IPublicClientApplication application) { + // Force-disable powerOptCheck so the Broker attempts the real + // network call instead of proactively blocking with a Doze check. + // This matches production Authenticator behavior (which doesn't + // have powerOptCheckEnabled set by OneAuth). + application.getConfiguration().setPowerOptCheckEnabled(false); + + Log.i(TAG, "PCA created, mode: " + + (application instanceof ISingleAccountPublicClientApplication + ? "SingleAccount" : "MultipleAccount")); + Log.i(TAG, "powerOptCheckEnabled forced to: " + + application.getConfiguration().isPowerOptCheckForEnabled()); + loadAccountsAndAcquire(application, scopeString, pendingResult); + } + + @Override + public void onError(final MsalException exception) { + Log.e(TAG, "Failed to create PCA: " + exception.getMessage(), exception); + pendingResult.finish(); + } + }); + } + + private void loadAccountsAndAcquire( + final IPublicClientApplication app, + final String scopeString, + final PendingResult pendingResult) { + + if (app instanceof ISingleAccountPublicClientApplication) { + final ISingleAccountPublicClientApplication singleApp = + (ISingleAccountPublicClientApplication) app; + try { + final IAccount account = singleApp.getCurrentAccount().getCurrentAccount(); + if (account == null) { + Log.e(TAG, "No signed-in account found in single-account mode."); + pendingResult.finish(); + return; + } + doSilentAuth(app, account, scopeString, pendingResult); + } catch (Exception e) { + Log.e(TAG, "Error loading account: " + e.getMessage(), e); + pendingResult.finish(); + } + } else if (app instanceof IMultipleAccountPublicClientApplication) { + final IMultipleAccountPublicClientApplication multiApp = + (IMultipleAccountPublicClientApplication) app; + multiApp.getAccounts(new IPublicClientApplication.LoadAccountsCallback() { + @Override + public void onTaskCompleted(final List result) { + if (result == null || result.isEmpty()) { + Log.e(TAG, "No accounts found in multiple-account mode."); + pendingResult.finish(); + return; + } + Log.i(TAG, "Found " + result.size() + " account(s). Using first."); + doSilentAuth(app, result.get(0), scopeString, pendingResult); + } + + @Override + public void onError(final MsalException exception) { + Log.e(TAG, "Error loading accounts: " + exception.getMessage(), exception); + pendingResult.finish(); + } + }); + } + } + + private void doSilentAuth( + final IPublicClientApplication app, + final IAccount account, + final String scopeString, + final PendingResult pendingResult) { + + Log.i(TAG, "Calling acquireTokenSilent for selected account."); + Log.i(TAG, "Authority: " + account.getAuthority()); + + final AcquireTokenSilentParameters parameters = new AcquireTokenSilentParameters.Builder() + .forAccount(account) + .fromAuthority(account.getAuthority()) + .withScopes(parseScopes(scopeString)) + .forceRefresh(true) // Force network call to eSTS (no cache) + .withCallback(new AuthenticationCallback() { + @Override + public void onSuccess(final IAuthenticationResult authenticationResult) { + Log.i(TAG, "=== SUCCESS === Token acquired silently!"); + Log.i(TAG, "Silent token acquisition completed successfully."); + pendingResult.finish(); + } + + @Override + public void onError(final MsalException exception) { + Log.e(TAG, "=== FAILED === " + exception.getClass().getSimpleName()); + Log.e(TAG, "Error code: " + exception.getErrorCode()); + Log.e(TAG, "Message: " + exception.getMessage()); + if (exception.getCause() != null) { + Log.e(TAG, "Cause: " + exception.getCause().getClass().getSimpleName() + + " - " + exception.getCause().getMessage()); + } + pendingResult.finish(); + } + + @Override + public void onCancel() { + Log.w(TAG, "=== CANCELLED ==="); + pendingResult.finish(); + } + }) + .build(); + + app.acquireTokenSilentAsync(parameters); + } + + private List parseScopes(final String scopeString) { + final String normalizedScopeString = StringUtil.isNullOrEmpty(scopeString) + ? DEFAULT_SCOPE + : scopeString.trim(); + final String[] rawScopes = normalizedScopeString.split("\\s+"); + final List parsedScopes = new ArrayList<>(); + + for (final String scope : rawScopes) { + if (!StringUtil.isNullOrEmpty(scope)) { + parsedScopes.add(scope); + } + } + + return parsedScopes; + } +}