Skip to content

Commit

Permalink
rework auth to use a list of servers (#1040)
Browse files Browse the repository at this point in the history
* configure maui to use the system web view for oauth

* rework servers to have a list of them.

* rework home page to have a list of servers with each project on those servers.

* don't push entry selection changes into browser history, back should take you home after you load a project

* ensure only crdt projects are shown as synced

* split loading local project and remote projects into two separate endpoints, this means the local list will load faster and not wait for a timeout if the user is offline

* provide feedback indicating that remote projects are loading

* use the Resilience extensions to retry on transient http failures
  • Loading branch information
hahn-kev authored Sep 4, 2024
1 parent 8148e41 commit 2f9974f
Show file tree
Hide file tree
Showing 21 changed files with 402 additions and 217 deletions.
5 changes: 4 additions & 1 deletion backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ public static void AddFwLiteDesktopServices(this IServiceCollection services,
config.ProjectPath = FileSystem.AppDataDirectory;
});
webAppBuilder.Services.Configure<AuthConfig>(config =>
config.CacheFileName = Path.Combine(FileSystem.AppDataDirectory, "msal.cache"));
{
config.CacheFileName = Path.Combine(FileSystem.AppDataDirectory, "msal.cache");
config.SystemWebViewLogin = true;
});
});
//using a lambda here means that the serverManager will be disposed when the app is disposed
services.AddSingleton<ServerManager>(_ => serverManager);
Expand Down
13 changes: 11 additions & 2 deletions backend/FwLite/LocalWebApp/Auth/AuthConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,16 @@ namespace LocalWebApp.Auth;
public class AuthConfig
{
[Required]
public required Uri DefaultAuthority { get; set; }
public required LexboxServer[] LexboxServers { get; set; }
public required string ClientId { get; set; }
public string CacheFileName { get; set; } = Path.GetFullPath("msal.cache");
public string CacheFileName { get; set; } = Path.GetFullPath("msal.json");
public bool SystemWebViewLogin { get; set; } = false;
public LexboxServer DefaultServer => LexboxServers.First();

public LexboxServer GetServer(string serverName)
{
return LexboxServers.FirstOrDefault(s => s.DisplayName == serverName) ?? throw new ArgumentException($"Server {serverName} not found");
}
}

public record LexboxServer(Uri Authority, string DisplayName);
44 changes: 28 additions & 16 deletions backend/FwLite/LocalWebApp/Auth/AuthHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,43 +43,48 @@ public AuthHelpers(LoggerAdapter loggerAdapter,
_logger = logger;
(var hostUrl, _isRedirectHostGuess) = urlContext.GetUrl();
_redirectHost = HostString.FromUriComponent(hostUrl);
var redirectUri = linkGenerator.GetUriByRouteValues(AuthRoutes.CallbackRoute, new RouteValueDictionary(), hostUrl.Scheme, _redirectHost);
var optionsValue = options.Value;
var redirectUri = options.Value.SystemWebViewLogin
? "http://localhost" //system web view will always have no path, changing this will not do anything in that case
: linkGenerator.GetUriByRouteValues(AuthRoutes.CallbackRoute,
new RouteValueDictionary(),
hostUrl.Scheme,
_redirectHost);
//todo configure token cache as seen here
//https://github.com/AzureAD/microsoft-authentication-extensions-for-dotnet/wiki/Cross-platform-Token-Cache
_application = PublicClientApplicationBuilder.Create(optionsValue.ClientId)
_application = PublicClientApplicationBuilder.Create(options.Value.ClientId)
.WithExperimentalFeatures()
.WithLogging(loggerAdapter, hostEnvironment.IsDevelopment())
.WithHttpClientFactory(new HttpClientFactoryAdapter(httpMessageHandlerFactory))
.WithRedirectUri(redirectUri)
.WithOidcAuthority(authority.ToString())
.Build();
_ = MsalCacheHelper.CreateAsync(BuildCacheProperties(optionsValue.CacheFileName)).ContinueWith(
_ = MsalCacheHelper.CreateAsync(BuildCacheProperties(options.Value.CacheFileName)).ContinueWith(
task =>
{
var msalCacheHelper = task.Result;
msalCacheHelper.RegisterCache(_application.UserTokenCache);
}, scheduler: TaskScheduler.Default);
},
scheduler: TaskScheduler.Default);
}

public static readonly KeyValuePair<string, string> LinuxKeyRingAttr1 = new("Version", "1");

public static readonly KeyValuePair<string, string> LinuxKeyRingAttr2 = new("ProductGroup", "Lexbox");

private static StorageCreationProperties BuildCacheProperties(string cacheFileName)
{
if (!Path.IsPathFullyQualified(cacheFileName)) throw new ArgumentException("Cache file name must be fully qualified");
if (!Path.IsPathFullyQualified(cacheFileName))
throw new ArgumentException("Cache file name must be fully qualified");
var propertiesBuilder =
new StorageCreationPropertiesBuilder(cacheFileName, Path.GetDirectoryName(cacheFileName));
#if DEBUG
propertiesBuilder.WithUnprotectedFile();
#else
const string KeyChainServiceName = "lexbox_msal_service";
const string KeyChainAccountName = "lexbox_msal_account";

const string LinuxKeyRingSchema = "org.sil.lexbox.tokencache";
const string LinuxKeyRingCollection = MsalCacheHelper.LinuxKeyRingDefaultCollection;
const string LinuxKeyRingLabel = "MSAL token cache for Lexbox.";

var propertiesBuilder = new StorageCreationPropertiesBuilder(cacheFileName, Directory.GetCurrentDirectory());
#if DEBUG
propertiesBuilder.WithUnprotectedFile();
#else
propertiesBuilder.WithLinuxKeyring(LinuxKeyRingSchema,
LinuxKeyRingCollection,
LinuxKeyRingLabel,
Expand All @@ -104,10 +109,9 @@ public HttpClient GetHttpClient()
}
}

public async Task<string> SignIn(CancellationToken cancellation = default)
public async Task<OAuthService.SignInResult> SignIn(CancellationToken cancellation = default)
{
var authUri = await _oAuthService.SubmitLoginRequest(_application, cancellation);
return authUri.ToString();
return await _oAuthService.SubmitLoginRequest(_application, cancellation);
}

public async Task Logout()
Expand All @@ -134,12 +138,20 @@ public async Task Logout()
{
_authResult = await _application.AcquireTokenSilent(DefaultScopes, account).ExecuteAsync();
}
catch (MsalClientException e) when (e.ErrorCode == "multiple_matching_tokens_detected")
{
_logger.LogWarning(e, "Multiple matching tokens detected, logging out");
await _application.RemoveAsync(account);
_authResult = null;
}
catch (MsalServiceException e) when (e.InnerException is HttpRequestException)
{
_logger.LogWarning(e, "Failed to acquire token silently");
await _application.RemoveAsync(account);//todo might not be the best way to handle this, maybe it's a transient error?
await _application
.RemoveAsync(account); //todo might not be the best way to handle this, maybe it's a transient error?
_authResult = null;
}

return _authResult;
}

Expand Down
17 changes: 6 additions & 11 deletions backend/FwLite/LocalWebApp/Auth/AuthHelpersFactory.cs
Original file line number Diff line number Diff line change
@@ -1,25 +1,15 @@
using System.Collections.Concurrent;
using LcmCrdt;
using Microsoft.Extensions.Options;

namespace LocalWebApp.Auth;

public class AuthHelpersFactory(
IServiceProvider provider,
ProjectContext projectContext,
IHttpContextAccessor contextAccessor,
IOptions<AuthConfig> options)
IHttpContextAccessor contextAccessor)
{
private readonly ConcurrentDictionary<string, AuthHelpers> _helpers = new();

/// <summary>
/// gets the default (as configured in the options) Auth Helper, usually for lexbox.org
/// </summary>
public AuthHelpers GetDefault()
{
return GetHelper(options.Value.DefaultAuthority);
}

private string AuthorityKey(Uri authority) =>
authority.GetComponents(UriComponents.HostAndPort, UriFormat.Unescaped);

Expand Down Expand Up @@ -53,6 +43,11 @@ public AuthHelpers GetHelper(ProjectData project)
return GetHelper(new Uri(originDomain));
}

public AuthHelpers GetHelper(LexboxServer server)
{
return GetHelper(server.Authority);
}

/// <summary>
/// get the auth helper for the current project, this method is used when trying to inject an AuthHelper into a service
/// </summary>
Expand Down
21 changes: 18 additions & 3 deletions backend/FwLite/LocalWebApp/Auth/OAuthService.cs
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
using System.Threading.Channels;
using System.Web;
using LocalWebApp.Utils;
using Microsoft.Extensions.Options;
using Microsoft.Identity.Client;
using Microsoft.Identity.Client.Extensibility;

namespace LocalWebApp.Auth;

//this class is commented with a number of step comments, these are the steps in the OAuth flow
//if a step comes before a method that means it awaits that call, if it comes after that means it resumes after the above await
public class OAuthService(ILogger<OAuthService> logger, IHostApplicationLifetime applicationLifetime) : BackgroundService
public class OAuthService(ILogger<OAuthService> logger, IHostApplicationLifetime applicationLifetime, IOptions<AuthConfig> options) : BackgroundService
{
public async Task<Uri> SubmitLoginRequest(IPublicClientApplication application, CancellationToken cancellation)
public record SignInResult(Uri? AuthUri, bool HandledBySystemWebView);
public async Task<SignInResult> SubmitLoginRequest(IPublicClientApplication application, CancellationToken cancellation)
{
if (options.Value.SystemWebViewLogin)
{
await HandleSystemWebViewLogin(application, cancellation);
return new(null, true);
}
var request = new OAuthLoginRequest(application);
if (!_requestChannel.Writer.TryWrite(request))
{
Expand All @@ -22,7 +29,15 @@ public async Task<Uri> SubmitLoginRequest(IPublicClientApplication application,
//step 4
if (request.State is null) throw new InvalidOperationException("State is null");
_oAuthLoginRequests[request.State] = request;
return uri;
return new(uri, false);
}

private async Task HandleSystemWebViewLogin(IPublicClientApplication application, CancellationToken cancellation)
{
var result = await application.AcquireTokenInteractive(AuthHelpers.DefaultScopes)
.WithUseEmbeddedWebView(false)
.WithSystemWebViewOptions(new() { })
.ExecuteAsync(cancellation);
}

public async Task<AuthenticationResult> FinishLoginRequest(Uri uri, CancellationToken cancellation = default)
Expand Down
2 changes: 1 addition & 1 deletion backend/FwLite/LocalWebApp/LocalWebApp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.4"/>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.8" />
<PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" Version="8.0.4" />
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.63.0" />
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.64.0" />
<PackageReference Include="Refit" Version="7.0.0" />
<PackageReference Include="Refit.HttpClientFactory" Version="7.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0"/>
Expand Down
7 changes: 5 additions & 2 deletions backend/FwLite/LocalWebApp/LocalWebAppServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,13 @@ public static WebApplication SetupAppServer(string[] args, Action<WebApplication
}

builder.ConfigureDev<AuthConfig>(config =>
config.DefaultAuthority = new("https://lexbox.dev.languagetechnology.org"));
config.LexboxServers = [
new (new("https://lexbox.dev.languagetechnology.org"), "Lexbox Dev"),
new (new("https://localhost:3000"), "Lexbox Local")
]);
//for now prod builds will also use lt dev until we deploy oauth to prod
builder.ConfigureProd<AuthConfig>(config =>
config.DefaultAuthority = new("https://lexbox.dev.languagetechnology.org"));
config.LexboxServers = [new(new("https://lexbox.dev.languagetechnology.org"), "Lexbox Dev")]);
builder.Services.Configure<AuthConfig>(c => c.ClientId = "becf2856-0690-434b-b192-a4032b72067f");

builder.Services.AddLocalAppServices(builder.Environment);
Expand Down
47 changes: 39 additions & 8 deletions backend/FwLite/LocalWebApp/Routes/AuthRoutes.cs
Original file line number Diff line number Diff line change
@@ -1,31 +1,62 @@
using System.Security.AccessControl;
using System.Web;
using LocalWebApp.Auth;
using Microsoft.Extensions.Options;

namespace LocalWebApp.Routes;

public static class AuthRoutes
{
public const string CallbackRoute = "AuthRoutes_Callback";
public record ServerStatus(string DisplayName, bool LoggedIn, string? LoggedInAs);
public static IEndpointConventionBuilder MapAuthRoutes(this WebApplication app)
{
var group = app.MapGroup("/api/auth").WithOpenApi();
group.MapGet("/login/default", async (AuthHelpersFactory factory) => Results.Redirect(await factory.GetDefault().SignIn()));
group.MapGet("/servers", (IOptions<AuthConfig> options, AuthHelpersFactory factory) =>
{
return options.Value.LexboxServers.ToAsyncEnumerable().SelectAwait(async s =>
{
var currentName = await factory.GetHelper(s).GetCurrentName();
return new ServerStatus(s.DisplayName,
!string.IsNullOrEmpty(currentName),
currentName);
});
});
group.MapGet("/login/{server}",
async (AuthHelpersFactory factory, string server, IOptions<AuthConfig> options) =>
{
var result = await factory.GetHelper(options.Value.GetServer(server)).SignIn();
if (result.HandledBySystemWebView)
{
return Results.Redirect("/");
}
if (result.AuthUri is null) throw new InvalidOperationException("AuthUri is null");
return Results.Redirect(result.AuthUri.ToString());
});
group.MapGet("/oauth-callback",
async (OAuthService oAuthService, HttpContext context) =>
{
var uriBuilder = new UriBuilder(context.Request.Scheme, context.Request.Host.Host, context.Request.Host.Port ?? 80, context.Request.Path);
var uriBuilder = new UriBuilder(context.Request.Scheme,
context.Request.Host.Host,
context.Request.Host.Port ?? 80,
context.Request.Path);
uriBuilder.Query = context.Request.QueryString.ToUriComponent();
await oAuthService.FinishLoginRequest(uriBuilder.Uri);
return Results.Redirect("/");
}).WithName(CallbackRoute);
group.MapGet("/me", async (AuthHelpersFactory factory) => new { name = await factory.GetDefault().GetCurrentName() });
group.MapGet("/logout/default", async (AuthHelpersFactory factory) =>
{
await factory.GetDefault().Logout();
return Results.Redirect("/");
});
group.MapGet("/me/{server}",
async (AuthHelpersFactory factory, string server, IOptions<AuthConfig> options) =>
{
return new { name = await factory.GetHelper(options.Value.GetServer(server)).GetCurrentName() };
});
group.MapGet("/logout/{server}",
async (AuthHelpersFactory factory, string server, IOptions<AuthConfig> options) =>
{
await factory.GetHelper(options.Value.GetServer(server)).Logout();
return Results.Redirect("/");
});
return group;
}
}
Loading

0 comments on commit 2f9974f

Please sign in to comment.