Skip to content

Commit

Permalink
Fix logout by patching WinUIEx
Browse files Browse the repository at this point in the history
  • Loading branch information
frederikprijck committed Oct 13, 2023
1 parent ebdcd60 commit 07efe9e
Show file tree
Hide file tree
Showing 4 changed files with 495 additions and 3 deletions.
5 changes: 3 additions & 2 deletions src/Auth0.OidcClient.MAUI/Auth0.OidcClient.MAUI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@
<PackageReference Include="IdentityModel.OidcClient" Version="5.2.1" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net6.0-windows10.0.19041.0'">
<PackageReference Include="WinUIEx">
<Version>2.2.0</Version>
<PackageReference Include="WinUIEx" Version="2.2.0">
</PackageReference>
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.3.230502000" />
</ItemGroup>
Expand All @@ -49,6 +48,7 @@
<None Remove="Platforms\MacCatalyst\" />
<None Remove="Platforms\iOS\" />
<None Remove="Platforms\Android\" />
<None Remove="WinUIExEx\" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
Expand All @@ -62,5 +62,6 @@
<Folder Include="Platforms\MacCatalyst\" />
<Folder Include="Platforms\iOS\" />
<Folder Include="Platforms\Android\" />
<Folder Include="WinUIExEx\" />
</ItemGroup>
</Project>
35 changes: 34 additions & 1 deletion src/Auth0.OidcClient.MAUI/WebAuthenticatorBrowser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,40 @@ public async Task<BrowserResult> InvokeAsync(BrowserOptions options, Cancellatio
try
{
#if WINDOWS
var result = await WinUIEx.WebAuthenticator.AuthenticateAsync(new Uri(options.StartUrl), new Uri(options.EndUrl));
if (options.StartUrl.IndexOf("logout") > -1)
{
WinUIExEx.WebAuthenticator.BeforeProcessStart = (uri) =>
{
var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
// The state QueryString as generated by WinUIEx
var state = query["state"];
// The original returnTo as configured externally
var returnTo = query["returnTo"];


UriBuilder returnToBuilder = new UriBuilder(returnTo);

// Get the original returnTo querystring params, so we can append state to it
var returnToQuery = System.Web.HttpUtility.ParseQueryString(new Uri(returnTo).Query);
// Append state as a querystring parameter to returnTo
// We need to escape it for it to be accepted
returnToQuery["state"] = Uri.EscapeDataString(state);
// Set the query again on the returnTo url
returnToBuilder.Query = returnToQuery.ToString();

// Update returnTo in the original query so that it now includes state
query["returnTo"] = returnToBuilder.Uri.ToString();

UriBuilder logoutUrlBuilder = new UriBuilder(uri);
// Set the query again on the logout url
logoutUrlBuilder.Query = query.ToString();

// Return the Uri so it can be used internally by WinUIEx to start the process and open the browser
return logoutUrlBuilder.Uri;
};
}

var result = await WinUIExEx.WebAuthenticator.AuthenticateAsync(new Uri(options.StartUrl), new Uri(options.EndUrl));
#else
var result = await WebAuthenticator.Default.AuthenticateAsync(new Uri(options.StartUrl), new Uri(options.EndUrl));
#endif
Expand Down
277 changes: 277 additions & 0 deletions src/Auth0.OidcClient.MAUI/WinUIExEx/WebAuthenticator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
/// Based on https://github.com/dotMorten/WinUIEx but modified to work for our use-case untill it's solved in WinUIEx.

#if WINDOWS
using WinUIEx;
namespace WinUIExEx
{
/// <summary>
/// Handles OAuth redirection to the system browser and re-activation.
/// </summary>
/// <remarks>
/// <para>
/// Your app must be configured for OAuth. In you app package's <c>Package.appxmanifest</c> under Declarations, add a
/// Protocol declaration and add the scheme you registered for your application's oauth redirect url under "Name".
/// </para>
/// </remarks>
public sealed class WebAuthenticator
{
public static Func<Uri, Uri>? BeforeProcessStart { get; set; }

/// <summary>
/// Begin an authentication flow by navigating to the specified url and waiting for a callback/redirect to the callbackUrl scheme.
/// </summary>
/// <param name="authorizeUri">Url to navigate to, beginning the authentication flow.</param>
/// <param name="callbackUri">Expected callback url that the navigation flow will eventually redirect to.</param>
/// <returns>Returns a result parsed out from the callback url.</returns>
/// <remarks>Prior to calling this, a call to <see cref="CheckOAuthRedirectionActivation(bool)"/> must be made during application startup.</remarks>
/// <seealso cref="CheckOAuthRedirectionActivation(bool)"/>
public static Task<WebAuthenticatorResult> AuthenticateAsync(Uri authorizeUri, Uri callbackUri) => Instance.Authenticate(authorizeUri, callbackUri, CancellationToken.None);

/// <summary>
/// Begin an authentication flow by navigating to the specified url and waiting for a callback/redirect to the callbackUrl scheme.
/// </summary>
/// <param name="authorizeUri">Url to navigate to, beginning the authentication flow.</param>
/// <param name="callbackUri">Expected callback url that the navigation flow will eventually redirect to.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Returns a result parsed out from the callback url.</returns>
/// <remarks>Prior to calling this, a call to <see cref="CheckOAuthRedirectionActivation(bool)"/> must be made during application startup.</remarks>
/// <seealso cref="CheckOAuthRedirectionActivation(bool)"/>
public static Task<WebAuthenticatorResult> AuthenticateAsync(Uri authorizeUri, Uri callbackUri, CancellationToken cancellationToken) => Instance.Authenticate(authorizeUri, callbackUri, cancellationToken);

private static readonly WebAuthenticator Instance = new WebAuthenticator();

private Dictionary<string, TaskCompletionSource<Uri>> tasks = new Dictionary<string, TaskCompletionSource<Uri>>();

private WebAuthenticator()
{
Microsoft.Windows.AppLifecycle.AppInstance.GetCurrent().Activated += CurrentAppInstance_Activated;
}

private static bool IsUriProtocolDeclared(string scheme)
{
if (global::Windows.ApplicationModel.Package.Current is null)
return false;
var docPath = Path.Combine(global::Windows.ApplicationModel.Package.Current.InstalledLocation.Path, "AppxManifest.xml");
var doc = XDocument.Load(docPath, LoadOptions.None);
var reader = doc.CreateReader();
var namespaceManager = new XmlNamespaceManager(reader.NameTable);
namespaceManager.AddNamespace("x", "http://schemas.microsoft.com/appx/manifest/foundation/windows10");
namespaceManager.AddNamespace("uap", "http://schemas.microsoft.com/appx/manifest/uap/windows10");

// Check if the protocol was declared
var decl = doc.Root?.XPathSelectElements($"//uap:Extension[@Category='windows.protocol']/uap:Protocol[@Name='{scheme}']", namespaceManager);

return decl != null && decl.Any();
}

private static System.Collections.Specialized.NameValueCollection? GetState(Microsoft.Windows.AppLifecycle.AppActivationArguments activatedEventArgs)
{
if (activatedEventArgs.Kind == Microsoft.Windows.AppLifecycle.ExtendedActivationKind.Protocol &&
activatedEventArgs.Data is IProtocolActivatedEventArgs protocolArgs)
{
return GetState(protocolArgs);
}
return null;
}

private static NameValueCollection? GetState(IProtocolActivatedEventArgs protocolArgs)
{
NameValueCollection? vals = null;
try
{
vals = System.Web.HttpUtility.ParseQueryString(protocolArgs.Uri.Query);
}
catch { }
try
{
if (vals is null || !(vals["state"] is string))
{
var fragment = protocolArgs.Uri.Fragment;
if (fragment.StartsWith("#"))
{
fragment = fragment.Substring(1);
}
vals = System.Web.HttpUtility.ParseQueryString(fragment);
}
}
catch { }
if (vals != null && vals["state"] is string state)
{
try
{
JsonObject? jsonObject;

try
{
jsonObject = System.Text.Json.Nodes.JsonObject.Parse(state) as JsonObject;
}
catch (Exception e)
{
jsonObject = System.Text.Json.Nodes.JsonObject.Parse(Uri.UnescapeDataString(state)) as JsonObject;
}

if (jsonObject is not null)
{
NameValueCollection vals2 = new NameValueCollection(jsonObject.Count);
if (jsonObject.ContainsKey("appInstanceId") && jsonObject["appInstanceId"] is JsonValue jvalue && jvalue.TryGetValue<string>(out string? value))
vals2.Add("appInstanceId", value);
if (jsonObject.ContainsKey("signinId") && jsonObject["signinId"] is JsonValue jvalue2 && jvalue2.TryGetValue<string>(out string? value2))
vals2.Add("signinId", value2);
return vals2;
}
}
catch { }
}
return null;
}
private static bool _oauthCheckWasPerformed;

/// <summary>
/// Performs an OAuth protocol activation check and redirects activation to the correct application instance.
/// </summary>
/// <param name="skipShutDownOnActivation">If <c>true</c>, this application instance will not automatically be shut down. If set to
/// <c>true</c> ensure you handle instance exit, or you'll end up with multiple instances running.</param>
/// <returns><c>true</c> if the activation was redirected and this instance should be shut down, otherwise <c>false</c>.</returns>
/// <remarks>
/// The call to this method should be done preferably in the Program.Main method, or the application constructor. It must be called
/// prior to using <see cref="AuthenticateAsync(Uri, Uri, CancellationToken)"/>
/// </remarks>
/// <seealso cref="AuthenticateAsync(Uri, Uri, CancellationToken)"/>
public static bool CheckOAuthRedirectionActivation(bool skipShutDownOnActivation = false)
{
var activatedEventArgs = Microsoft.Windows.AppLifecycle.AppInstance.GetCurrent()?.GetActivatedEventArgs();
return CheckOAuthRedirectionActivation(activatedEventArgs, skipShutDownOnActivation);
}

/// <summary>
/// Performs an OAuth protocol activation check and redirects activation to the correct application instance.
/// </summary>
/// <param name="activatedEventArgs">The activation arguments</param>
/// <param name="skipShutDownOnActivation">If <c>true</c>, this application instance will not automatically be shut down. If set to
/// <c>true</c> ensure you handle instance exit, or you'll end up with multiple instances running.</param>
/// <returns><c>true</c> if the activation was redirected and this instance should be shut down, otherwise <c>false</c>.</returns>
/// <remarks>
/// The call to this method should be done preferably in the Program.Main method, or the application constructor. It must be called
/// prior to using <see cref="AuthenticateAsync(Uri, Uri, CancellationToken)"/>
/// </remarks>
/// <seealso cref="AuthenticateAsync(Uri, Uri, CancellationToken)"/>
public static bool CheckOAuthRedirectionActivation(AppActivationArguments? activatedEventArgs, bool skipShutDownOnActivation = false)
{
_oauthCheckWasPerformed = true;
if (activatedEventArgs is null)
return false;
if (activatedEventArgs.Kind != Microsoft.Windows.AppLifecycle.ExtendedActivationKind.Protocol)
return false;
var state = GetState(activatedEventArgs);
if (state is not null && state["appInstanceId"] is string id && state["signinId"] is string signinId && !string.IsNullOrEmpty(signinId))
{
var instance = Microsoft.Windows.AppLifecycle.AppInstance.GetInstances().Where(i => i.Key == id).FirstOrDefault();

if (instance is not null && !instance.IsCurrent)
{
// Redirect to correct instance and close this one
instance.RedirectActivationToAsync(activatedEventArgs).AsTask().Wait();
if (!skipShutDownOnActivation)
System.Diagnostics.Process.GetCurrentProcess().Kill();
return true;
}
}
else
{
var thisInstance = Microsoft.Windows.AppLifecycle.AppInstance.GetCurrent();
if (string.IsNullOrEmpty(thisInstance.Key))
{
Microsoft.Windows.AppLifecycle.AppInstance.FindOrRegisterForKey(Guid.NewGuid().ToString());
}
}
return false;
}

private void CurrentAppInstance_Activated(object? sender, Microsoft.Windows.AppLifecycle.AppActivationArguments e)
{
if (e.Kind == Microsoft.Windows.AppLifecycle.ExtendedActivationKind.Protocol)
{
if (e.Data is IProtocolActivatedEventArgs protocolArgs)
{
var vals = GetState(protocolArgs);
if (vals is not null && vals["signinId"] is string signinId)
{
ResumeSignin(protocolArgs.Uri, signinId);
}
}
}
}

private void ResumeSignin(Uri callbackUri, string signinId)
{
if (signinId != null && tasks.ContainsKey(signinId))
{
var task = tasks[signinId];
tasks.Remove(signinId);
task.TrySetResult(callbackUri);
}
}

private async Task<WebAuthenticatorResult> Authenticate(Uri authorizeUri, Uri callbackUri, CancellationToken cancellationToken)
{
if (!_oauthCheckWasPerformed)
{
throw new InvalidOperationException("OAuth redirection check on app activation was not detected. Please make sure a call to WebAuthenticator.CheckOAuthRedirectionActivation was made during App creation.");
}
if (!Helpers.IsAppPackaged)
{
throw new InvalidOperationException("The WebAuthenticator requires a packaged app with an AppxManifest");
}
if (!IsUriProtocolDeclared(callbackUri.Scheme))
{
throw new InvalidOperationException($"The URI Scheme {callbackUri.Scheme} is not declared in AppxManifest.xml");
}
var g = Guid.NewGuid();
var taskId = g.ToString();
UriBuilder b = new UriBuilder(authorizeUri);

var query = System.Web.HttpUtility.ParseQueryString(authorizeUri.Query);
var stateJson = new JsonObject
{
{ "appInstanceId", Microsoft.Windows.AppLifecycle.AppInstance.GetCurrent().Key },
{ "signinId", taskId }
};
if (query["state"] is string oldstate && !string.IsNullOrEmpty(oldstate))
{
stateJson["state"] = oldstate;
}

query["state"] = stateJson.ToJsonString();
b.Query = query.ToString();
authorizeUri = b.Uri;

var tcs = new TaskCompletionSource<Uri>();
if (cancellationToken.CanBeCanceled)
{
cancellationToken.Register(() =>
{
tcs.TrySetCanceled();
if (tasks.ContainsKey(taskId))
tasks.Remove(taskId);
});
if (cancellationToken.IsCancellationRequested)
{
cancellationToken.ThrowIfCancellationRequested();
}
}

var newUri = BeforeProcessStart != null ? BeforeProcessStart(authorizeUri) : authorizeUri;

var process = new System.Diagnostics.Process();
process.StartInfo.FileName = "rundll32.exe";
process.StartInfo.Arguments = $"url.dll,FileProtocolHandler \"{newUri.ToString().Replace("\"", "%22")}\"";
process.StartInfo.UseShellExecute = true;
process.Start();
tasks.Add(taskId, tcs);
var uri = await tcs.Task.ConfigureAwait(false);
return new WebAuthenticatorResult(uri);
}
}
}

#endif
Loading

0 comments on commit 07efe9e

Please sign in to comment.