This post is the next installment in my series titled Connect a .NET Desktop Client to a Custom ASP.NET Core Service (EF Core with pure Web API). The content relates to my previously published post Modern Desktop Apps And Their Complex Architectures and aims to illustrate an application system architecture that includes a data access service in addition to a WinForms application with a DevExpress Data Grid.
In the previous post of this series, I demonstrated how to add a log-in feature to the application, and to make security decisions selectively based on tokens returned by a Keycloak service. I pointed out at the time that the implementation used the Resource Owner Password Credentials (ROPC) flow, and I made that first step because that flow is familiar in the context of traditional client applications. When a user logs in, an app-specific dialog queries username and password, then sends that information to a server for verification.
Table of Contents
- Intro — Modern Desktop Apps And Their Complex Architectures
- Choosing a Framework/App Architecture for Desktop & Mobile Cross-Platform Apps / GitHub sample
- Connect a .NET Desktop Client (WinForms Data Grid) to a Custom ASP.NET Core Service (EF Core with pure Web API)
- Connect a .NET Desktop Client (WinForms Data Grid) to a Secure Backend Web API Service (EF Core with OData)
- Connect a .NET Desktop Client (WinForms Data Grid) to a Middle Tier Security Server (EF Core with WebSockets)
- TBD — Azure Databases with Data API Builder
- TBD — GraphQL APIs
We also have related blog series, which may be of interest for you as well: JavaScript — Consume the DevExpress Backend Web API with Svelte (7 parts from data editing to validation, localization, reporting).
From ROPC to Authorization Code Flow
As I also mentioned previously, this ROPC flow is not a mechanism that is considered very secure. The main reason is that the client application needs to have at least temporary knowledge of the credentials entered by the user, which means that the user must trust the application and its developers, and that the developers are fully responsible for the fate of those credentials. Of course we all hope that we never make mistakes, but additional security can be had quite easily if we find a way to authenticate a user without ever providing the client application with the credentials in any reusable form.
One solution to this problem is the Authorization Code Flow. Microsoft’s documentation for this flow is somewhat Azure and Microsoft Entra ID specific, but it includes some useful flow diagrams that describe the steps of the process in some detail. Feel free to read this for background information.
Like the previous post, this example uses Keycloak. If you’d like to try the code for yourself, please find the setup instructions in this section of the previous post. In the sections below, we will walk through the following steps to update the demo application for the Authorization Code Flow :
- Update the Keycloak configuration to register a client with an updated security configuration
- Accept redirections from the Keycloak service in the WinForms client by registering a protocol handler and passing on any received details to a running app instance
- Remove the old login form and modify the authentication process to implement the Authorization Code Flow
An interesting note is that no changes at all are required to the data service. Everything that needs to be changed is on the client side. While the flow that provides the token to the client will be changed completely in this update to the demo, the token itself and the information it contains remain exactly the same.
Update the Keycloak Configuration
For the previous version of the sample application, the
Keycloak service was configured with a client registration that
supported Direct Access Grants , which was necessary
for the ROPC flow. You can leave this client registration in
place (though for a real-world setup it would be safer to
delete it!) and create a new registration to support the
Authorization Code Flow now. However, before you do
this, check the Client scopes of your realm to make
sure the openid
scope is included there.
Since Keycloak has a focus on OAuth, theopenid
scope may not be included by default in some versions of
Keycloak.
If you don’t see the scope listed, please add it using the
Create client scope button. Just enter the name
openid
, select the protocol
OpenID Connect and the type Default , then
save the entry.
Now you can register a new client using the
Create client button. The client type is
OpenID Connect and you can set your own client ID. For
the description and the sample code, I’ll use
appAuthFlow1
as the client ID.
On the page Capability config there is a section titled Authentication flow. This includes the checkbox Direct access grant , and this item may be checked by default. Make sure to uncheck it now! But leave Standard flow active, since that’s what you need for the Authorization code flow .
Finally, on the Login settings page, you need to
define one or more valid redirect URIs. I’ll use
winappdemo://*
for this sample, but you can make
up your own protocol prefix if you like, or specify in more
detail what the text following the protocol part should look
like. For purposes of this sample I will always use the string
default
with the protocol prefix, so it would be
possible to lock things down further by including it in the URI
here.
Once you have added the client, switch to the tab
Client scopes— for the client registration, not the
main menu item of the same name! — and make sure that the
openid
scope is included. It should be added
automatically if you created it first as described, but you can
also add it later in case it shouldn’t be there.
Image may be NSFW.
Clik here to view.
Accept redirections in WinForms using a protocol handler
As the name indicates, the idea of the Authorization Code Flow is that the authentication service returns a code to the client side which indicates that valid user credentials have been provided. Step number one of the flow is, therefore, that the user is prompted to log in by a page provided by the authentication service itself, and not by the client application. This typically works by directing the client to a web page, as you will see in a little while.
Once the user has provided valid credentials and logged into the authentication service, the code that is generated is typically returned to the client by way of an automatic redirection. This means that the user’s browser is redirected to a URL, and this URL is specific to the client application that is waiting for the code. The URL is in fact specified by the client application together with the initial request, but the service can determine whether a given URL is valid for the purpose or not. You already configured your client registration on Keycloak to validate those URLs based on the protocol prefix.
It is possible to handle the redirect in various different ways. For example, you could run your own HTTP service inside your client application and react to incoming requests directly. However, Windows also has a feature that allows you to register your own protocol prefix and then your application will simply be run as a command by Windows and the details that are passed through by the URL are supplied as command line arguments. This is the approach I chose to use for this demo application.
The first step you need to take for this mechanism to work is
to register your protocol in the registry. If your application
uses an installer, it can handle this at install time. However,
you can also easily create the necessary registry entries
programatically. Here’s the method
RegisterProtocol
that will run on startup in the
demo app.
static void RegisterProtocol()
{
string customProtocol = "winappdemo";
string applicationPath = Application.ExecutablePath;
var keyPath = $@"Software\Classes\{customProtocol}";
using (var key = Registry.CurrentUser.CreateSubKey(keyPath, true))
{
if (key == null)
{
throw new Exception($"Registry key can't be written: {keyPath}");
}
key.SetValue(string.Empty, "URL:" + customProtocol);
key.SetValue("URL Protocol", string.Empty);
using (var commandKey = key.CreateSubKey(@"shell\open\command"))
{
commandKey.SetValue(string.Empty, $"{applicationPath} %1");
}
}
}
The main parts of this are the name of the custom protocol, which of course has to correspond to your Valid URI configuration on the Keycloak side, and the command which runs whenever the custom protocol is encountered.
For the demo app I chose to implement a mechanism that uses a named pipe to send any incoming protocol information to an already running instance of the same application. I assume that under normal circumstances there should be a running instance already, since the user login process would have to be triggered from that instance. Of course, it is also possible to use several instances of the application at once — it depends on your requirements whether you would like to accept multiple instances running at once or restrict your application to just one instance at a time.
In startup, if no parameters are detected on the command line, the application attempts to start a listener for the named pipe.
private const string pipeName = "WinAppDemoProtocolMessagePipe";
...
static void StartProtocolMessageListener()
{
Task.Run(async () =>
{
while (true)
{
using var server = new NamedPipeServerStream(pipeName, PipeDirection.In);
server.WaitForConnection();
using var reader = new StreamReader(server);
var msg = reader.ReadToEnd();
await HandleProtocolMessage(msg);
}
});
}
static async Task HandleProtocolMessage(string msg)
{
Console.WriteLine($"Handling protocol message: '{msg}'");
await DataServiceClient.AcceptProtocolUrl(msg);
}
The method HandleProtocolMessage
passes the
message on to the class DataServiceClient
which
you might be familiar with from previous versions of my sample.
This is where further handling will occur on the UI level and
I’ll get back to it in a moment.
If there is a parameter detected on the command line, the application attempts to send the details to a running instance initially.
static void SendProtocolMessage(string msg)
{
using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.Out);
client.Connect(500);
using var writer = new StreamWriter(client) { AutoFlush = true };
writer.Write(msg);
}
The Connect
call uses a short timeout. If no
running instance is found, there is an error in the process.
Implement Authorization Code Flow
A couple of small changes have been applied to the
MainForm
in the demo app. The
LoginForm
has been removed from the project
because it is not needed anymore, and the login logic is now
split between the login method, which simply calls the
DataServiceClient
, and an event handler that
reacts to any login status changes in the
DataServiceClient
and changes the UI appearance
accordingly.
The method DataServiceClient.LogIn
is where the
Authorization Code Flow begins. The helper
Url.Combine
from the highly recommended
Flurl
library is used to construct the required URL with all its
parts. To keep things generic in the demo, I use a simple
Process.Start
call to execute the default browser
configured in the system to bring up the login page on the
Keycloak server.
var url = Url.Combine(authUrl, "realms", realm, "protocol", "openid-connect", "auth")
.SetQueryParams(
new
{
response_type = "code",
client_id = clientId,
redirect_uri = redirectUri,
scope = "openid profile email",
}
);
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
The redirectUri
is a new entry in
App.config
, and I set it to
winappdemo://default
. Note that the
scope
includes the value openid
—
this is why you configured the Keycloak service to support that
scope. It is required so that the service returns an ID token
as well as the access and refresh tokens, which is necessary to
log out correctly later.
After the user has logged in successfully with the Keycloak
service, the redirect URI is used and Windows runs the second
instance of the application, which results in a call to
AcceptProtocolUrl
on the
DataServiceClient
— you have seen this above
already.
The code in AcceptProtocolUrl
is similar to what
we used before to retrieve a token from Keycloak. The
difference is that now we use the code that was returned after
the initial roundtrip, instead of sending username and password
details directly. Here’s what this part of the logic looks like
now:
public static async Task AcceptProtocolUrl(string protocolUrlString)
{
var protocolUrl = new Url(protocolUrlString);
if (
protocolUrl.QueryParams.TryGetFirst("code", out object codeObject)
&& codeObject is string code
)
{
CheckSettings(["authUrl", "realm", "clientId", "redirectUri"]);
var content = new FormUrlEncodedContent(
new Dictionary<string, string>
{
{ "grant_type", "authorization_code" },
{ "client_id", clientId! },
{ "code", code },
{ "redirect_uri", redirectUri! },
}
);
var url = Url.Combine(
authUrl,
"realms",
realm,
"protocol",
"openid-connect",
"token"
);
var response = await bareHttpClient.PostAsync(url, content);
...
If you compare this to the previous version, you will find that
the difference is largely in the parameters that are sent to
the server, which now include the grant_type
authorization_code
and of course the code itself,
redirect URI.
The method GetTokens
has been extended to return
the id_token
in addition to the
access_token
, refresh_token
and the
expires_in
value. This is relevant to log the user
out from the server when required. If you don’t log out, the
browser will remember a previously logged in user for a while.
Of course this could be a security issue in cases where
multiple users have access to the same machine, but for us
developers it is also important because we can’t test the login
mechanism with the two demo accounts reader
and
writer
as long as the browser does not bring up
the login window again because it thinks we’re already logged
in!
The LogOut
method looks like this:
public static void LogOut()
{
var url = Url.Combine(authUrl, "realms", realm, "protocol", "openid-connect", "logout")
.SetQueryParams(
new { post_logout_redirect_uri = redirectUri, id_token_hint = idToken }
);
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
This summarizes all the important changes and it is time now to test the new login functionality. After the login process has been triggered in the client application, the user finds themselves in a browser logging into the Keycloak service. As before, the demo accounts are “reader” and “writer”, with the passwords the same as the usernames.
Image may be NSFW.
Clik here to view.
When you click the Sign In button, the browser redirects as instructed, and this process brings up a confirmation dialog, at least the first time it happens — depending on the browser it may be possible to confirm that future attempts should be confirmed automatically.
Image may be NSFW.
Clik here to view.
Once the redirection is handled by the WinForms app, the token is fetched and evaluated as before. You can then access the data as permitted by the user’s roles, log out and back in, and test the functionality in detail.
A final note about the mechanisms used in this demo implementation. Developers often wonder whether it isn’t too much of a hassle to deal with the browser window and the tabs that may stay open afterwards, etc — I don’t believe this is a problem. Users today are quite familiar with similar mechanisms, as they are used by many everyday websites and apps, even when watching TV with your Fire Stick or similar devices.
You should definitely resist the temptation to do everything “internally”. For instance, a frequently asked question is whether we couldn’t use a hosted web browser control in the app instead of a separate browser. Technically we could, but it is an important part of the security strategy here that the browser is a separate environment which is not controlled or observed by the application itself. If you feel that the login mechanism described here is not perfect for your needs, I recommend checking out some other options! For instance, the Device Authorization Grant can be useful, enabling a user to log in to a system using a separate device, perhaps a mobile phone with a QR code.
Your Feedback Matters!
You can also download our GitHub example and play with different configurations on your own. Please send your feedback — thanks to all of you who have already done that! — and any questions or ideas, we will attempt to consider everything!
Clik here to view.