Skip to content

Commit fc7a6ab

Browse files
authored
Handle offline login gracefully instead of a raw MSAL error (#2372)
1 parent 7bd2a81 commit fc7a6ab

17 files changed

Lines changed: 224 additions & 10 deletions

File tree

backend/FwLite/FwLiteShared.Tests/Auth/OAuthClientFailureClassifierTests.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,36 @@ public void AnyOtherFailure_KeepsCachedCredentials(Exception e)
4444

4545
outcome.Should().Be(OAuthClient.SilentAuthFailureOutcome.KeepCachedCredentials);
4646
}
47+
48+
[Fact]
49+
public void OidcFetchFailure_IsOffline()
50+
{
51+
//the real-world offline error: MSAL fails to fetch the OIDC config, wrapping the connection failure
52+
var e = new MsalServiceException("oidc_failure", "Failed to retrieve OIDC configuration",
53+
new HttpRequestException("Connection failure"));
54+
55+
var result = OAuthClient.ClassifyInteractiveLoginFailure(e);
56+
57+
result.Should().Be(LoginResult.Offline);
58+
}
59+
60+
[Fact]
61+
public void UserCancel_IsCancelled()
62+
{
63+
//a cancel is a cancel regardless of connectivity; offline-with-warm-cache also lands here
64+
var e = new MsalClientException(MsalError.AuthenticationCanceledError, "User canceled authentication");
65+
66+
var result = OAuthClient.ClassifyInteractiveLoginFailure(e);
67+
68+
result.Should().Be(LoginResult.Cancelled);
69+
}
70+
71+
[Fact]
72+
public void UnexpectedFailure_IsNotClassified_SoCallerRethrows()
73+
{
74+
var result = OAuthClient.ClassifyInteractiveLoginFailure(
75+
new InvalidOperationException("something unexpected"));
76+
77+
result.Should().BeNull();
78+
}
4779
}

backend/FwLite/FwLiteShared/Auth/AuthService.cs

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,26 @@
1+
using System.Text.Json.Serialization;
12
using FwLiteShared.Projects;
3+
using Microsoft.Extensions.Logging;
24
using Microsoft.Extensions.Options;
35
using Microsoft.JSInterop;
46

57
namespace FwLiteShared.Auth;
68

79
public record ServerStatus(string DisplayName, bool LoggedIn, string? LoggedInAs, LexboxServer Server);
8-
public class AuthService(LexboxProjectService lexboxProjectService, OAuthClientFactory clientFactory, IOptions<AuthConfig> options)
10+
11+
[JsonConverter(typeof(JsonStringEnumConverter))]
12+
public enum LoginResult
13+
{
14+
Success,
15+
Offline,
16+
Cancelled,
17+
}
18+
19+
public class AuthService(
20+
LexboxProjectService lexboxProjectService,
21+
OAuthClientFactory clientFactory,
22+
ILogger<AuthService> logger,
23+
IOptions<AuthConfig> options)
924
{
1025
[JSInvokable]
1126
public async Task<ServerStatus[]> Servers()
@@ -21,11 +36,22 @@ public async Task<ServerStatus[]> Servers()
2136
}
2237

2338
[JSInvokable]
24-
public async Task SignInWebView(LexboxServer server)
39+
public async Task<LoginResult> SignInWebView(LexboxServer server)
2540
{
26-
var result = await clientFactory.GetClient(server).SignIn(string.Empty);//does nothing here
27-
if (!result.HandledBySystemWebView) throw new InvalidOperationException("Sign in not handled by system web view");
28-
options.Value.AfterLoginWebView?.Invoke();
41+
try
42+
{
43+
var result = await clientFactory.GetClient(server).SignIn(string.Empty);//does nothing here
44+
if (!result.HandledBySystemWebView) throw new InvalidOperationException("Sign in not handled by system web view");
45+
options.Value.AfterLoginWebView?.Invoke();
46+
return LoginResult.Success;
47+
}
48+
catch (Exception e)
49+
{
50+
var classified = OAuthClient.ClassifyInteractiveLoginFailure(e);
51+
if (classified is null) throw;
52+
logger.LogInformation(e, "Web view sign in did not complete: {LoginResult}", classified);
53+
return classified.Value;
54+
}
2955
}
3056

3157
[JSInvokable]

backend/FwLite/FwLiteShared/Auth/OAuthClient.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,14 @@ internal enum SilentAuthFailureOutcome
300300
_ => SilentAuthFailureOutcome.KeepCachedCredentials,
301301
};
302302

303+
internal static LoginResult? ClassifyInteractiveLoginFailure(Exception e) => e switch
304+
{
305+
MsalServiceException { InnerException: HttpRequestException } => LoginResult.Offline,
306+
HttpRequestException or OperationCanceledException => LoginResult.Offline,
307+
MsalClientException { ErrorCode: MsalError.AuthenticationCanceledError } => LoginResult.Cancelled,
308+
_ => null,
309+
};
310+
303311
public async Task<string?> GetCurrentName()
304312
{
305313
var auth = await GetAuth();

backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder)
148148
builder.ExportAsEnum<ProjectRole>().UseString();
149149
builder.ExportAsEnum<MorphTypeKind>().UseString();
150150
builder.ExportAsEnum<SyncStatus>().UseString();
151+
builder.ExportAsEnum<LoginResult>().UseString();
151152
builder.ExportAsEnum<DownloadProjectByCodeResult>().UseString();
152153
builder.ExportAsEnum<SyncJobStatusEnum>().UseString();
153154
builder.ExportAsEnum<ViewBase>().UseString();

frontend/viewer/src/lib/auth/LoginButton.svelte

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@
1616
<script lang="ts">
1717
import * as ResponsiveMenu from '$lib/components/responsive-menu';
1818
import type {ILexboxServer} from '$lib/dotnet-types';
19+
import {LoginResult} from '$lib/dotnet-types';
1920
import {useAuthService} from '$lib/services/service-provider';
2021
import {Button} from '$lib/components/ui/button';
22+
import {AppNotification} from '$lib/notifications/notifications';
23+
import {openUrl} from '$lib/services/url-opener';
2124
2225
const authService = useAuthService();
2326
const shouldUseSystemWebView = useSystemWebView(authService);
@@ -40,8 +43,25 @@
4043
async function login(server: ILexboxServer) {
4144
loading = true;
4245
try {
43-
await authService.signInWebView(server);
44-
statusChange('logged-in');
46+
const result = await authService.signInWebView(server);
47+
48+
if (result === LoginResult.Success) {
49+
statusChange('logged-in');
50+
} else if (result === LoginResult.Offline) {
51+
AppNotification.displayAction(
52+
$t`You appear to be offline. Can you connect to ${server.displayName}?`,
53+
{
54+
label: $t`Open in browser`,
55+
callback: () => {
56+
void openUrl(server.authority);
57+
return {dismiss: true};
58+
},
59+
},
60+
{type: 'warning'},
61+
);
62+
} else {
63+
AppNotification.display($t`Login cancelled.`, {type: 'warning', timeout: 'short'});
64+
}
4565
} finally {
4666
loading = false;
4767
}
@@ -56,6 +76,7 @@
5676
loading = false;
5777
}
5878
}
79+
5980
</script>
6081

6182
{#if status.loggedIn}

frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Auth/IAuthService.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44
// the code is regenerated.
55

66
import type {IServerStatus} from './IServerStatus';
7+
import type {LoginResult} from './LoginResult';
78
import type {ILexboxServer} from './ILexboxServer';
89

910
export interface IAuthService
1011
{
1112
servers() : Promise<IServerStatus[]>;
12-
signInWebView(server: ILexboxServer) : Promise<void>;
13+
signInWebView(server: ILexboxServer) : Promise<LoginResult>;
1314
useSystemWebView() : Promise<boolean>;
1415
logout(server: ILexboxServer) : Promise<void>;
1516
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/* eslint-disable */
2+
// This code was generated by a Reinforced.Typings tool.
3+
// Changes to this file may cause incorrect behavior and will be lost if
4+
// the code is regenerated.
5+
6+
export enum LoginResult {
7+
Success = "Success",
8+
Offline = "Offline",
9+
Cancelled = "Cancelled"
10+
}
11+
/* eslint-enable */

frontend/viewer/src/lib/dotnet-types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from './generated-types/FwLiteShared/Auth/IAuthService';
22
export * from './generated-types/FwLiteShared/Auth/ILexboxServer';
33
export * from './generated-types/FwLiteShared/Auth/IServerStatus';
4+
export * from './generated-types/FwLiteShared/Auth/LoginResult';
45
export * from './generated-types/FwLiteShared/Projects/ICombinedProjectsService';
56
export * from './generated-types/FwLiteShared/Projects/IProjectModel';
67
export * from './generated-types/FwLiteShared/Projects/IServerProjects';

frontend/viewer/src/locales/en.po

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,6 +1122,11 @@ msgstr "Local only"
11221122
msgid "Login"
11231123
msgstr "Login"
11241124

1125+
#. Toast shown when the user closes or cancels the login dialog before signing in.
1126+
#: src/lib/auth/LoginButton.svelte
1127+
msgid "Login cancelled."
1128+
msgstr "Login cancelled."
1129+
11251130
#. Default button label on the login button when not yet authenticated. Clicking opens the login flow for a server.
11261131
#: src/lib/auth/LoginButton.svelte
11271132
#: src/lib/auth/LoginButton.svelte
@@ -1409,6 +1414,11 @@ msgstr "Open"
14091414
msgid "Open Data Directory"
14101415
msgstr "Open Data Directory"
14111416

1417+
#. Action button on the offline-login warning toast; opens the server's site in a browser so the user can check the connection.
1418+
#: src/lib/auth/LoginButton.svelte
1419+
msgid "Open in browser"
1420+
msgstr "Open in browser"
1421+
14121422
#. Button label
14131423
#: src/lib/components/OpenInFieldWorksButton.svelte
14141424
msgid "Open in FieldWorks"
@@ -2166,6 +2176,11 @@ msgstr "Writing systems"
21662176
msgid "Writing Systems"
21672177
msgstr "Writing Systems"
21682178

2179+
#. Warning toast shown when a login attempt can't connect to the server. {0} is the server name (e.g. "Lexbox").
2180+
#: src/lib/auth/LoginButton.svelte
2181+
msgid "You appear to be offline. Can you connect to {0}?"
2182+
msgstr "You appear to be offline. Can you connect to {0}?"
2183+
21692184
#. Status message when no updates are available
21702185
#: src/lib/updates/UpdateDialogContent.svelte
21712186
msgid "You are running the latest version."

frontend/viewer/src/locales/es.po

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1127,6 +1127,11 @@ msgstr "Sólo local"
11271127
msgid "Login"
11281128
msgstr "Inicio de sesión"
11291129

1130+
#. Toast shown when the user closes or cancels the login dialog before signing in.
1131+
#: src/lib/auth/LoginButton.svelte
1132+
msgid "Login cancelled."
1133+
msgstr ""
1134+
11301135
#. Default button label on the login button when not yet authenticated. Clicking opens the login flow for a server.
11311136
#: src/lib/auth/LoginButton.svelte
11321137
#: src/lib/auth/LoginButton.svelte
@@ -1414,6 +1419,11 @@ msgstr "Abrir"
14141419
msgid "Open Data Directory"
14151420
msgstr "Directorio de datos abiertos"
14161421

1422+
#. Action button on the offline-login warning toast; opens the server's site in a browser so the user can check the connection.
1423+
#: src/lib/auth/LoginButton.svelte
1424+
msgid "Open in browser"
1425+
msgstr ""
1426+
14171427
#. Button label
14181428
#: src/lib/components/OpenInFieldWorksButton.svelte
14191429
msgid "Open in FieldWorks"
@@ -2171,6 +2181,10 @@ msgstr "Sistemas de escritura"
21712181
msgid "Writing Systems"
21722182
msgstr "Sistemas de escritura"
21732183

2184+
#: src/lib/auth/LoginButton.svelte
2185+
msgid "You appear to be offline. Can you connect to {0}?"
2186+
msgstr ""
2187+
21742188
#. Status message when no updates are available
21752189
#: src/lib/updates/UpdateDialogContent.svelte
21762190
msgid "You are running the latest version."

0 commit comments

Comments
 (0)