Skip to content

Commit 35ce25a

Browse files
authored
fix(templates): fix race condition in popup based social sign-in #10941 (#10942)
1 parent 220c146 commit 35ce25a

File tree

18 files changed

+131
-92
lines changed

18 files changed

+131
-92
lines changed

src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/ForceUpdateSnackBar.razor.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ protected override async Task OnInitAsync()
2121
if (isShown) return;
2222

2323
isShown = true;
24-
await bitSnackBar.Success(string.Empty);
24+
await bitSnackBar.Error(string.Empty);
2525
});
2626
}
2727

src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public partial class SignInPanel
1818
private SignInPanelType internalSignInPanelType;
1919
private readonly SignInRequestDto model = new();
2020
private AppDataAnnotationsValidator? validatorRef;
21-
private string ReturnUrl => ReturnUrlQueryString ?? NavigationManager.GetRelativePath() ?? Urls.HomePage;
21+
private string ReturnUrl => ReturnUrlQueryString ?? Urls.HomePage;
2222

2323

2424
[Parameter, SupplyParameterFromQuery(Name = "return-url")]

src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/HybridAppWebInterop.razor renamed to src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/WebInteropApp.razor

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1+
@*+:cnd:noEmit*@
12
@*
2-
Leveraging Web Features in Blazor Hybrid:
3-
4-
1. WebView Integration: Directly utilize standard web technologies like CSS animations and external frames (e.g., Google reCaptcha) within the Blazor WebView.
5-
6-
2. Addressing Origin Restrictions for Sensitive Web APIs:
7-
- Due to the our WebView's fixed origin (http://0.0.0.1), certain security-sensitive web features like WebAuthn and social sign-in will not function directly within the WebView.
8-
- Solution for WebAuthn: Employ a local HTTP server (see WindowsLocalHttpServer and MauiHttpLocalServer) to serve the HybridAppWebInterop component via a GET endpoint. Use IExternalNavigationService to open an in-app browser, facilitating authentication (e.g., Face ID).
9-
- Limitation for Social Sign-in: Social sign-in providers generally restrict WebView usage due to security considerations, regardless of the origin.
10-
11-
3. Utilizing Website Origin (Such as https://adminpanel.bitplatform.dev):
12-
- For scenarios requiring your website's actual origin or other features incompatible even with a localhost origin, use IExternalNavigationService to navigate to the /HybridAppWebInterop minimal API endpoint (defined in HybridWebAppInteropEndpoint.cs).
3+
Most web features like animations, videos, or Google reCAPTCHA work fine in Blazor Hybrid.
4+
However, since Blazor Hybrid uses Web View with an IP-based Origin (http://0.0.0.1)
5+
more sensitive features like Social Sign-in and Face-ID/Fingerprint Sign-in using WebAuthn do not function properly in Web View.
6+
To address this, within a Blazor Hybrid App, you can use a Local HTTP Server to load a lightweight WebInteropApp component that
7+
only loads app.js (without Blazor.web.js) on an Origin like localhost.
8+
Then, using IExternalNavigationService, you can display it via an In-App Browser to bypass Web View limitations.
9+
10+
Additionally, in a Web Browser, if Social Sign-in is performed in a Popup or a new Tab to preserve the app's
11+
state and avoid restarting Blazor upon returning from Social Sign-in, the Popup should redirect back to WebAppInterop
12+
after authentication. Using app.js, it can notify the main Window via window.opener.postMessage({ key: 'PUBLISH_MESSAGE', ... }),
13+
allowing the main Window to resume its operations.
1314
*@
1415

1516
@code {
@@ -111,6 +112,12 @@ Leveraging Web Features in Blazor Hybrid:
111112
}
112113
}
113114
</style>
115+
116+
@*#if (appInsights == true)*@
117+
<link rel="preconnect" href="https://js.monitor.azure.com" crossorigin />
118+
<!-- Perform the initial static render of ApplicationInsightsInit to start App Insights ASAP. -->
119+
<BlazorApplicationInsights.ApplicationInsightsInit />
120+
@*#endif*@
114121
</head>
115122
<body>
116123
<div class="title">@Localizer[nameof(AppStrings.PleaseWait)]</div>
@@ -123,7 +130,7 @@ Leveraging Web Features in Blazor Hybrid:
123130
<script src="_content/Bit.Butil/bit-butil.js"></script>
124131
<script src="_content/Boilerplate.Client.Core/scripts/app.js"></script>
125132
<script type="text/javascript">
126-
HybridAppWebInterop.run();
133+
WebInteropApp.run();
127134
</script>
128135
</body>
129136
</html>

src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/HybridAppWebInterop.ts renamed to src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/WebAppInterop.ts

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Read Components/HybridAppWebInterop.razor comments.
1+
// Read Components/WebInteropApp.razor comments.
22

33
declare class BitButil {
44
static webAuthn: {
@@ -7,48 +7,71 @@ declare class BitButil {
77
}
88
};
99

10-
class HybridAppWebInterop {
10+
class WebInteropApp {
11+
12+
private static autoClose = true;
13+
1114
public static async run() {
1215
try {
1316
const urlParams = new URLSearchParams(location.search);
1417
const action = urlParams.get('actionName');
1518
switch (action) {
1619
case 'SocialSignInCallback':
17-
await HybridAppWebInterop.socialSignInCallback();
20+
await WebInteropApp.socialSignInCallback();
1821
break;
1922
case 'GetWebAuthnCredential':
20-
await HybridAppWebInterop.getWebAuthnCredential();
23+
await WebInteropApp.getWebAuthnCredential();
2124
break;
2225
case 'CreateWebAuthnCredential':
23-
await HybridAppWebInterop.createWebAuthnCredential();
26+
await WebInteropApp.createWebAuthnCredential();
2427
break;
2528
}
2629
}
2730
catch (err: any) {
28-
const errMsg = `${JSON.stringify(err, Object.getOwnPropertyNames(err))} ${err.toString()}`;
29-
await fetch('api/LogError', {
30-
method: 'POST',
31-
credentials: 'omit',
32-
body: errMsg
33-
});
31+
const urlParams = new URLSearchParams(location.search);
32+
const localHttpPort = urlParams.get('localHttpPort')?.toString();
33+
if (localHttpPort) {
34+
// Blazor Hybrid:
35+
const errMsg = `${JSON.stringify(err, Object.getOwnPropertyNames(err))} ${err.toString()}`;
36+
await fetch('api/LogError', {
37+
method: 'POST',
38+
credentials: 'omit',
39+
body: errMsg
40+
});
41+
}
3442
}
3543
finally {
36-
window.close();
37-
location.href = 'about:blank';
44+
if (WebInteropApp.autoClose) {
45+
window.close();
46+
location.href = 'about:blank';
47+
}
3848
}
3949
}
4050

4151
private static async socialSignInCallback() {
4252
const urlParams = new URLSearchParams(location.search);
4353
const urlToOpen = urlParams.get('url')!.toString();
44-
const localHttpPort = Number.parseInt(urlParams.get('localHttpPort')!.toString());
54+
const localHttpPort = urlParams.get('localHttpPort')?.toString();
55+
if (!localHttpPort) {
56+
// Blazor WebAssembly, Auto or Server:
57+
if (window.opener) {
58+
window.opener.postMessage({ key: 'PUBLISH_MESSAGE', message: 'SOCIAL_SIGN_IN', payload: urlToOpen });
59+
}
60+
else {
61+
WebInteropApp.autoClose = false;
62+
location.href = urlToOpen;
63+
}
64+
return;
65+
}
66+
// Blazor Hybrid:
4567
await fetch(`http://localhost:${localHttpPort}/api/SocialSignInCallback?urlToOpen=${encodeURIComponent(urlToOpen)}`, {
4668
method: 'POST',
4769
credentials: 'omit'
4870
});
4971
}
5072

5173
private static async getWebAuthnCredential() {
74+
// Blazor Hybrid:
5275
const webAuthnCredentialOptions = await (await fetch(`api/GetWebAuthnCredentialOptions`, { credentials: 'omit' })).json();
5376
const webAuthnCredential = await BitButil.webAuthn.getCredential(webAuthnCredentialOptions);
5477
await fetch(`api/WebAuthnCredential`, {
@@ -63,6 +86,7 @@ class HybridAppWebInterop {
6386
}
6487

6588
private static async createWebAuthnCredential() {
89+
// Blazor Hybrid:
6690
const webAuthnCredentialOptions = await (await fetch(`api/GetCreateWebAuthnCredentialOptions`, { credentials: 'omit' })).json();
6791
const webAuthnCredential = await BitButil.webAuthn.createCredential(webAuthnCredentialOptions);
6892
await fetch(`api/CreateWebAuthnCredential`, {

src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/app.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -104,15 +104,6 @@ function handleMessage(e: MessageEvent) {
104104

105105
function handleLoad() {
106106
setCssWindowSizes();
107-
if (window.opener != null && (location.pathname == '/sign-in' || location.pathname == '/sign-up')) {
108-
// The IExternalNavigationService is responsible for opening pages in a new window,
109-
// such as during social sign-in flows. Once the external navigation is complete,
110-
// and the user is redirected back to the newly opened window,
111-
// the following code ensures that the original window is notified.
112-
// If IExternalNavigationService fails to navigate to the new window (Typically on iOS/Safari), the window.opener will be null and the page normally loads.
113-
window.opener.postMessage({ key: 'PUBLISH_MESSAGE', message: 'SOCIAL_SIGN_IN', payload: window.location.href });
114-
window.close();
115-
}
116107
}
117108

118109
function setCssWindowSizes() {

src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Services/Contracts/IExternalNavigationService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
namespace Boilerplate.Client.Core.Services.Contracts;
22

3-
// Check out HybridAppWebInterop.razor's comments.
3+
// Check out WebInteropApp.razor's comments.
44
public interface IExternalNavigationService
55
{
66
Task NavigateToAsync(string url);
Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1-
namespace Boilerplate.Client.Core.Services;
1+
namespace Boilerplate.Client.Core.Services;
22

3-
public partial class DefaultExternalNavigationService : IExternalNavigationService
3+
public partial class DefaultExternalNavigationService : IExternalNavigationService, IAsyncDisposable
44
{
55
[AutoInject] private readonly Window window = default!;
6+
[AutoInject] private readonly PubSubService pubSubService = default!;
67
[AutoInject] private readonly NavigationManager navigationManager = default!;
78

89
/// <summary>
910
/// The MauiExternalNavigationService (Client.Maui) implementation of <see cref="IExternalNavigationService"/> can show one window at a time
1011
/// on Android and iOS apps. Trying to have similar UX across platforms, we close the last opened window before opening the new one in web platform as well.
1112
/// </summary>
1213
private string? lastOpenedWindowId = null;
14+
private Action? pubSubUnsubscribe;
1315

1416
public async Task NavigateToAsync(string url)
1517
{
@@ -20,17 +22,32 @@ public async Task NavigateToAsync(string url)
2022
return;
2123
}
2224

23-
2425
// Client.Web:
2526
if (lastOpenedWindowId is not null)
2627
{
27-
await window.Close(lastOpenedWindowId);
28+
await window.Close(lastOpenedWindowId); // Only one open window at a time.
2829
}
2930

30-
if ((lastOpenedWindowId = await window.Open(url, "_blank", new WindowFeatures() { Popup = true, Height = 768, Width = 1024 })) is null // Let's try with popup first.
31+
if ((lastOpenedWindowId = await window.Open(url, "_blank", new WindowFeatures() { Popup = true, Width = 1024, Height = 768 })) is null // Let's try with popup first.
3132
&& (lastOpenedWindowId = await window.Open(url, "_blank", new WindowFeatures() { Popup = false })) is null) // Let's try new tab
3233
{
3334
navigationManager.NavigateTo(url, forceLoad: true, replace: true); // If all else fails, let's try to navigate in the same tab.
3435
}
36+
else
37+
{
38+
pubSubUnsubscribe?.Invoke();
39+
pubSubUnsubscribe = pubSubService.Subscribe(ClientPubSubMessages.SOCIAL_SIGN_IN, async _ =>
40+
{
41+
if (lastOpenedWindowId != null)
42+
{
43+
await window.Close(lastOpenedWindowId); // It's time to close the social sign-in popup / tab.
44+
}
45+
});
46+
}
47+
}
48+
49+
public async ValueTask DisposeAsync()
50+
{
51+
pubSubUnsubscribe?.Invoke();
3552
}
3653
}

src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Maui/Services/MauiLocalHttpServer.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
namespace Boilerplate.Client.Maui.Services;
1010

11-
// Checkout HybridAppWebInterop.razor's comments.
11+
// Checkout WebInteropApp.razor's comments.
1212
public partial class MauiLocalHttpServer : ILocalHttpServer
1313
{
1414
[AutoInject] private HtmlRenderer htmlRenderer;
@@ -143,10 +143,10 @@ await MainThread.InvokeOnMainThreadAsync(() =>
143143

144144
await GoBackToApp();
145145
}))
146-
.WithModule(new ActionModule("/hybrid-app-web-interop", HttpVerbs.Get, async ctx =>
146+
.WithModule(new ActionModule("/web-interop-app", HttpVerbs.Get, async ctx =>
147147
{
148148
var html = await htmlRenderer.Dispatcher.InvokeAsync(async () =>
149-
(await htmlRenderer.RenderComponentAsync<HybridAppWebInterop>()).ToHtmlString());
149+
(await htmlRenderer.RenderComponentAsync<WebInteropApp>()).ToHtmlString());
150150

151151
await ctx.SendStringAsync(html, "text/html", Encoding.UTF8);
152152
}))

src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Maui/Services/MauiWebAuthnService.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public override async ValueTask<JsonElement> GetWebAuthnCredential(JsonElement o
1515

1616
((MauiLocalHttpServer)localHttpServer).WebAuthnService = this;
1717

18-
await externalNavigationService.NavigateToAsync($"http://localhost:{localHttpServer.Port}/hybrid-app-web-interop?actionName=GetWebAuthnCredential");
18+
await externalNavigationService.NavigateToAsync($"http://localhost:{localHttpServer.Port}/web-interop-app?actionName=GetWebAuthnCredential");
1919

2020
return await GetWebAuthnCredentialTcs.Task;
2121
}
@@ -30,7 +30,7 @@ public override async ValueTask<JsonElement> CreateWebAuthnCredential(JsonElemen
3030

3131
((MauiLocalHttpServer)localHttpServer).WebAuthnService = this;
3232

33-
await externalNavigationService.NavigateToAsync($"http://localhost:{localHttpServer.Port}/hybrid-app-web-interop?actionName=CreateWebAuthnCredential");
33+
await externalNavigationService.NavigateToAsync($"http://localhost:{localHttpServer.Port}/web-interop-app?actionName=CreateWebAuthnCredential");
3434

3535
return await CreateWebAuthnCredentialTcs.Task;
3636
}

src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Web/wwwroot/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
<script>
1111
// disable auto-zoom of iOS Safari when focusing an input
1212
(/iPad|iPhone|iPod/.test(navigator.userAgent)) &&
13-
(document.querySelector('meta[name="viewport"]').content = 'width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0')
13+
(document.querySelector('meta[name="viewport"]').content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover');
1414
</script>
1515

1616
<!--#if (captcha == "reCaptcha")-->

0 commit comments

Comments
 (0)