Skip to content

Commit de46f30

Browse files
authored
Handle target="_blank" links in authorization WebView, Fixes AB#3535641 (#3010)
**Problem** Total loss recovery is a feature provided by VID to recover the user's account. This gets initiated from inside of the webview. See below screenshot for reference. Once the user clicks on "Recover your account", the flow goes through a set of redirects - sometimes to 3P identity verifiers where they may handle button clicks differently. They may contain links with target="_blank" (ideally used in websites where they want to open a new tab upon a button or link click)(e.g., Terms of Use, privacy links), the Android WebView silently drops these navigations. Users tap the link and nothing happens — no browser opens, no feedback is given. **Solution** Override onCreateWindow to intercept target="_blank" navigations and open them in the device's default external browser via intent. Note : This will ONLY be enabled for TLR links. The check is made in OnPageLoadedCallback method of WebViewAuthorizationFragment. - **How are we gating it only for TLR URLs?** : When a page is loaded, we know its url, we check if it matches the TLR url pattern.. If it does, we enable multiple windows support in webview by calling the method setSupportMultipleWindows(true). Else, it is the same as today (set to false). - **Would onCreateWindow method be called even when setSupportMultipleWindows is false?** : No, it completely needs setSupportMultipleWindows to be true. So, the code in onCreateWindow need not be behind a flight as the code triggering it is already behind the flight. **Feature gating** The change is gated behind ENABLE_WEBVIEW_MULTIPLE_WINDOWS CommonFlight: default false (safe for MSAL/non-broker callers) BrokerFlight: overrides to true (enabled in broker context) **Telemetry** The change also involves adding a new span with default sampling rate. But we expect this span to be emitted in very low numbers (10 to 20 per day) once Total loss recovery feature is enabled. Once this feature is stabilized, we can remove this span. Fixes [AB#3535641](https://identitydivision.visualstudio.com/fac9d424-53d2-45c0-91b5-ef6ba7a6bf26/_workitems/edit/3535641)
1 parent f1aa8ca commit de46f30

7 files changed

Lines changed: 464 additions & 2 deletions

File tree

changelog.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ vNext
44
- [MINOR] Remove LruCache from SharedPreferencesFileManager (#2910)
55
- [MINOR] Edge TB: Claims (#2925)
66
- [PATCH] Update Moshi to 1.15.2 to resolve okio CVE-2023-3635 vulnerability (#3005)
7+
- [MINOR] Handle target="_blank" links in authorization WebView (#3010)
78

89
Version 24.0.1
910
----------

common/src/main/java/com/microsoft/identity/common/adal/internal/AuthenticationConstants.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1481,6 +1481,36 @@ public static String computeMaxHostBrokerProtocol() {
14811481
*/
14821482
public static final String REDIRECT_SSL_PREFIX = "https://";
14831483

1484+
/**
1485+
* Path segment for the TLR (Total loss recovery) start page.
1486+
*/
1487+
public static final String TLR_START_PATH = "/tlr/start";
1488+
1489+
/**
1490+
* WebView routing value when the target URL from a target=_blank navigation is null.
1491+
*/
1492+
public static final String WEBVIEW_TARGET_BLANK_ROUTE_NULL_URL = "null_url";
1493+
1494+
/**
1495+
* WebView routing value when the target URL is not SSL-protected.
1496+
*/
1497+
public static final String WEBVIEW_TARGET_BLANK_ROUTE_NON_SSL = "non_ssl";
1498+
1499+
/**
1500+
* WebView routing value when the target URL is loaded inline (non-TLR page).
1501+
*/
1502+
public static final String WEBVIEW_TARGET_BLANK_ROUTE_NON_TLR = "non_tlr_flow";
1503+
1504+
/**
1505+
* WebView routing value when the target URL is delegated to the system browser (TLR page).
1506+
*/
1507+
public static final String WEBVIEW_TARGET_BLANK_ROUTE_TLR = "tlr_flow";
1508+
1509+
/**
1510+
* WebView routing value when the popup was not initiated by a user gesture.
1511+
*/
1512+
public static final String WEBVIEW_TARGET_BLANK_ROUTE_NO_USER_GESTURE = "no_user_gesture";
1513+
14841514
/**
14851515
* Prefix in the redirect for PlayStore.
14861516
*/

common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,17 @@
4040
import android.net.Uri;
4141
import android.os.Build;
4242
import android.os.Bundle;
43+
import android.os.Message;
4344
import android.view.LayoutInflater;
4445
import android.view.MotionEvent;
4546
import android.view.View;
4647
import android.view.ViewGroup;
4748
import android.webkit.PermissionRequest;
4849
import android.webkit.WebChromeClient;
50+
import android.webkit.WebResourceRequest;
4951
import android.webkit.WebSettings;
5052
import android.webkit.WebView;
53+
import android.webkit.WebViewClient;
5154
import android.widget.ProgressBar;
5255

5356
import androidx.activity.result.ActivityResultLauncher;
@@ -79,6 +82,7 @@
7982
import com.microsoft.identity.common.java.util.StringUtil;
8083
import com.microsoft.identity.common.logging.Logger;
8184

85+
import com.microsoft.identity.common.java.opentelemetry.AttributeName;
8286
import java.io.UnsupportedEncodingException;
8387
import java.net.URLEncoder;
8488
import java.util.Arrays;
@@ -87,8 +91,14 @@
8791

8892
import static com.microsoft.identity.common.java.AuthenticationConstants.OAuth2.UTID;
8993

94+
import com.microsoft.identity.common.java.opentelemetry.OTelUtility;
95+
import com.microsoft.identity.common.java.opentelemetry.SpanExtension;
96+
import com.microsoft.identity.common.java.opentelemetry.SpanName;
9097

98+
import io.opentelemetry.api.trace.Span;
9199
import io.opentelemetry.api.trace.SpanContext;
100+
import io.opentelemetry.api.trace.StatusCode;
101+
import io.opentelemetry.context.Scope;
92102

93103
/**
94104
* Authorization fragment with embedded webview.
@@ -147,6 +157,7 @@ public void onCreate(@Nullable Bundle savedInstanceState) {
147157
if (activity != null) {
148158
WebViewUtil.setDataDirectorySuffix(activity.getApplicationContext());
149159
}
160+
150161
if (CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_LEGACY_FIDO_SECURITY_KEY_LOGIC)
151162
&& Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
152163
mFidoLauncher = registerForActivityResult(
@@ -264,6 +275,14 @@ public void onPageLoaded(final String url) {
264275
if (!mAuthResultSent && !StringExtensions.isNullOrBlank(javascriptToExecute[0])) {
265276
mWebView.evaluateJavascript(javascriptToExecute[0], null);
266277
}
278+
279+
// Dynamically toggle multiple-windows support so that target="_blank"
280+
// interception is active ONLY on the TLR start page. On all other
281+
// pages the WebView behaves exactly as before.
282+
if (CommonFlightsManager.INSTANCE.getFlightsProvider()
283+
.isFlightEnabled(CommonFlight.ENABLE_WEBVIEW_MULTIPLE_WINDOWS)) {
284+
mWebView.getSettings().setSupportMultipleWindows(isTlrUrl(url));
285+
}
267286
}
268287
},
269288
mRedirectUri,
@@ -352,6 +371,7 @@ public boolean onTouch(final View view, final MotionEvent event) {
352371
mWebView.getSettings().setUseWideViewPort(true);
353372
mWebView.getSettings().setBuiltInZoomControls(webViewZoomControlsEnabled);
354373
mWebView.getSettings().setSupportZoom(webViewZoomEnabled);
374+
355375
mWebView.setVisibility(View.INVISIBLE);
356376
mWebView.setWebViewClient(webViewClient);
357377
mWebView.setWebChromeClient(new WebChromeClient() {
@@ -376,10 +396,125 @@ public Bitmap getDefaultVideoPoster() {
376396
// We will return a 10x10 empty image, instead of the default grey playback image. #2424
377397
return Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888);
378398
}
399+
400+
@Override
401+
public boolean onCreateWindow(final WebView view, boolean isDialog,
402+
boolean isUserGesture, final Message resultMsg) {
403+
if (resultMsg.obj == null) {
404+
Logger.error(methodTag, "onCreateWindow: resultMsg.obj is null, cannot set up transport.", null);
405+
return false;
406+
}
407+
408+
final SpanContext parentSpanContext = requireActivity() instanceof AuthorizationActivity
409+
? ((AuthorizationActivity) requireActivity()).getSpanContext() : null;
410+
final Span span = OTelUtility.createSpanFromParent(
411+
SpanName.WebViewTargetBlankNavigation.name(), parentSpanContext);
412+
boolean windowHandled = false;
413+
try (final Scope scope = SpanExtension.makeCurrentSpan(span)) {
414+
Logger.info(methodTag, "onCreateWindow: intercepting target=_blank navigation.");
415+
final WebView interceptorWebView = new WebView(view.getContext());
416+
interceptorWebView.setWebViewClient(new WebViewClient() {
417+
@Override
418+
public boolean shouldOverrideUrlLoading(WebView v, WebResourceRequest request) {
419+
handleInterceptedUrlFromNewWindow(view, v, request, span, isUserGesture);
420+
return true;
421+
}
422+
});
423+
final WebView.WebViewTransport transport = (WebView.WebViewTransport) resultMsg.obj;
424+
transport.setWebView(interceptorWebView);
425+
resultMsg.sendToTarget();
426+
// Span status and end are handled in handleInterceptedUrlFromNewWindow,
427+
// which fires asynchronously when shouldOverrideUrlLoading is called.
428+
windowHandled = true;
429+
} catch (@NonNull final Exception e) {
430+
Logger.error(methodTag, "Error handling target=_blank navigation.", e);
431+
span.recordException(e);
432+
span.setStatus(StatusCode.ERROR);
433+
span.end();
434+
}
435+
return windowHandled;
436+
}
379437
});
380438
setupPasskeyWebListener(mWebView, webViewClient);
381439
}
382440

441+
/**
442+
* Handles the URL intercepted from a target=_blank navigation (onCreateWindow).
443+
* Routes the URL based on whether the main WebView is currently on a TLR page:
444+
* - TLR page: opens the URL in an external browser.
445+
* - Non-TLR page: loads the URL inline in the main WebView.
446+
*
447+
* @param mainWebView The main authentication WebView.
448+
* @param interceptorWebView The temporary interceptor WebView (will be destroyed after handling).
449+
* @param request The intercepted URL request.
450+
* @param span The telemetry span to record which routing path is taken.
451+
* @param isUserGesture Whether the popup was initiated by a user gesture (e.g. a click).
452+
*/
453+
@VisibleForTesting
454+
void handleInterceptedUrlFromNewWindow(@NonNull final WebView mainWebView,
455+
@NonNull final WebView interceptorWebView,
456+
@NonNull final WebResourceRequest request,
457+
@NonNull final Span span,
458+
final boolean isUserGesture) {
459+
final String methodTag = TAG + ":handleInterceptedUrlFromNewWindow";
460+
try {
461+
final String targetUrl = request.getUrl().toString();
462+
final String currentPageUrl = mainWebView.getUrl();
463+
464+
if (targetUrl == null) {
465+
span.setAttribute(AttributeName.target_blank_navigation_route.name(), AuthenticationConstants.Broker.WEBVIEW_TARGET_BLANK_ROUTE_NULL_URL);
466+
Logger.warn(methodTag, "onCreateWindow: target URL is null, ignoring.");
467+
} else if (!isUserGesture) {
468+
// Not initiated by user gesture: load inline as a safe fallback instead of
469+
// opening an external browser, to prevent programmatic/scripted popups.
470+
span.setAttribute(AttributeName.target_blank_navigation_route.name(), AuthenticationConstants.Broker.WEBVIEW_TARGET_BLANK_ROUTE_NO_USER_GESTURE);
471+
Logger.warn(methodTag, "onCreateWindow: popup not initiated by user gesture, loading URL inline.");
472+
mainWebView.loadUrl(targetUrl);
473+
} else if (!targetUrl.toLowerCase().startsWith(AuthenticationConstants.Broker.REDIRECT_SSL_PREFIX)) {
474+
// Non-SSL URL: refuse to open, matching AzureActiveDirectoryWebViewClient behavior.
475+
span.setAttribute(AttributeName.target_blank_navigation_route.name(), AuthenticationConstants.Broker.WEBVIEW_TARGET_BLANK_ROUTE_NON_SSL);
476+
Logger.error(methodTag, "onCreateWindow: URL is not SSL protected, refusing to open.", null);
477+
} else if (!isTlrUrl(currentPageUrl)) {
478+
// Non-TLR page: load inline, same as WebView default behavior.
479+
span.setAttribute(AttributeName.target_blank_navigation_route.name(), AuthenticationConstants.Broker.WEBVIEW_TARGET_BLANK_ROUTE_NON_TLR);
480+
Logger.warn(methodTag, "onCreateWindow: non-TLR page, loading URL inline as fallback.");
481+
mainWebView.loadUrl(targetUrl);
482+
} else {
483+
// TLR page: delegate to system browser so user can view terms externally.
484+
span.setAttribute(AttributeName.target_blank_navigation_route.name(), AuthenticationConstants.Broker.WEBVIEW_TARGET_BLANK_ROUTE_TLR);
485+
Logger.info(methodTag, "onCreateWindow: TLR page, delegating URL to system browser.");
486+
final Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(targetUrl));
487+
mainWebView.getContext().startActivity(browserIntent);
488+
}
489+
span.setStatus(StatusCode.OK);
490+
} catch (final Exception e) {
491+
span.recordException(e);
492+
span.setStatus(StatusCode.ERROR);
493+
Logger.error(methodTag, "Error handling target=_blank URL.", e);
494+
} finally {
495+
span.end();
496+
// Destroy the interceptor WebView after it has served its purpose
497+
interceptorWebView.post(interceptorWebView::destroy);
498+
}
499+
}
500+
501+
/**
502+
* Checks whether the given URL corresponds to a TLR (Terms, License, and Restrictions)
503+
* start page.
504+
*
505+
* @param url The URL to check.
506+
* @return {@code true} if the URL is a TLR start page, {@code false} otherwise.
507+
*/
508+
@VisibleForTesting
509+
boolean isTlrUrl(@Nullable final String url) {
510+
if (url == null) {
511+
return false;
512+
}
513+
final String lowerUrl = url.toLowerCase();
514+
return lowerUrl.startsWith(AuthenticationConstants.Broker.REDIRECT_SSL_PREFIX)
515+
&& lowerUrl.contains(AuthenticationConstants.Broker.TLR_START_PATH);
516+
}
517+
383518
/**
384519
* Loads starting authorization request url into WebView.
385520
*/

0 commit comments

Comments
 (0)