Skip to content

Commit 6986d85

Browse files
authored
Merge pull request #230 from petrsnd/feature/227-and-228-connect-async
Connect Async
2 parents 5564b76 + 9b175ea commit 6986d85

17 files changed

Lines changed: 1059 additions & 1242 deletions

File tree

DEVICE-CODE-PLAN.md

Lines changed: 0 additions & 995 deletions
This file was deleted.

README.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,19 @@ var connection = DefaultBrowserLogin.Connect("safeguard.sample.corp");
164164
Console.WriteLine(connection.InvokeMethod(Service.Core, Method.Get, "Me"));
165165
```
166166

167+
#### Async with CancellationToken
168+
169+
```C#
170+
using OneIdentity.SafeguardDotNet;
171+
using OneIdentity.SafeguardDotNet.BrowserLogin;
172+
173+
// Ctrl+C cancels the login flow without terminating the process
174+
using var cts = Safeguard.AgentBasedLoginUtils.CreateConsoleCancellationToken();
175+
var connection = await DefaultBrowserLogin.ConnectAsync(
176+
"safeguard.sample.corp", ignoreSsl: true, cancellationToken: cts.Token);
177+
Console.WriteLine(connection.InvokeMethod(Service.Core, Method.Get, "Me"));
178+
```
179+
167180
### Device Code Login (Recommended for Headless/Remote Devices)
168181

169182
When there is no browser on the local machine (SSH sessions, containers, IoT
@@ -221,6 +234,17 @@ var connection = PkceNoninteractiveLogin.Connect(
221234
Console.WriteLine(connection.InvokeMethod(Service.Core, Method.Get, "Me"));
222235
```
223236

237+
#### Async with CancellationToken
238+
239+
```C#
240+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
241+
SecureString password = GetPasswordSomehow();
242+
var connection = await PkceNoninteractiveLogin.ConnectAsync(
243+
"safeguard.sample.corp", "local", "Admin", password,
244+
ignoreSsl: true, cancellationToken: cts.Token);
245+
Console.WriteLine(connection.InvokeMethod(Service.Core, Method.Get, "Me"));
246+
```
247+
224248
### Username / Password (Resource Owner Grant — Legacy)
225249

226250
The following method relies on the Resource Owner grant, which is disabled by
@@ -280,6 +304,42 @@ A four minute video demonstrating how to get started calling the Safeguard API f
280304

281305
[![Visual Studio Code video](https://img.youtube.com/vi/gV7iHUun9kA/0.jpg)](https://www.youtube.com/watch?v=gV7iHUun9kA)
282306

307+
## Migrating to Async Login (ConnectAsync)
308+
309+
`DefaultBrowserLogin`, `PkceNoninteractiveLogin`, and `DeviceCodeLogin` now
310+
offer `ConnectAsync` methods with `CancellationToken` support.
311+
312+
**What changed in `Connect()` (sync):**
313+
314+
- `DefaultBrowserLogin.Connect()` no longer registers a `Console.CancelKeyPress`
315+
handler. The previous handler did not suppress process termination
316+
(`e.Cancel` was not set to `true`), so Ctrl+C already terminated the process.
317+
This change removes a non-functional side effect — behavior is unchanged for
318+
callers.
319+
320+
**New capability — `ConnectAsync`:**
321+
322+
```C#
323+
// Option 1: Ctrl+C cancellation that suppresses process termination
324+
using var cts = Safeguard.AgentBasedLoginUtils.CreateConsoleCancellationToken();
325+
var connection = await DefaultBrowserLogin.ConnectAsync(
326+
"safeguard.example.com", ignoreSsl: true, cancellationToken: cts.Token);
327+
328+
// Option 2: Custom timeout
329+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
330+
var connection = await PkceNoninteractiveLogin.ConnectAsync(
331+
"safeguard.example.com", "local", "Admin", password,
332+
ignoreSsl: true, cancellationToken: cts.Token);
333+
```
334+
335+
**Bug fix — `ignoreSsl` now honored everywhere:**
336+
337+
Previously, internal HTTP calls during token exchange always bypassed SSL
338+
validation regardless of the `ignoreSsl` parameter. This is now fixed — if you
339+
pass `ignoreSsl: false`, certificate validation is enforced for all calls.
340+
Environments using self-signed certificates should pass `ignoreSsl: true`
341+
explicitly.
342+
283343
## About the Safeguard API
284344

285345
The Safeguard API is a REST-based Web API. Safeguard API endpoints are called

SafeguardDotNet.BrowserLogin/AuthorizationCodeExtractor.cs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
namespace OneIdentity.SafeguardDotNet.BrowserLogin;
44

5+
using System;
56
using System.Net;
67
using System.Net.Sockets;
78
using System.Text;
89
using System.Text.RegularExpressions;
910
using System.Threading;
11+
using System.Threading.Tasks;
1012
using System.Web;
1113

1214
public class AuthorizationCodeExtractor
@@ -17,6 +19,80 @@ public AuthorizationCodeExtractor()
1719

1820
public string AuthorizationCode { get; set; }
1921

22+
/// <summary>
23+
/// Listens for an OAuth authorization code callback on the specified port (async).
24+
/// Returns the authorization code extracted from the browser redirect.
25+
/// </summary>
26+
/// <remarks>
27+
/// WARNING: This method blocks indefinitely until the browser callback is received.
28+
/// If no <paramref name="cancellationToken"/> is provided, the call will never return
29+
/// if the user does not complete authentication. Always provide a cancellation token
30+
/// with a timeout or use <see cref="Safeguard.AgentBasedLoginUtils.CreateConsoleCancellationToken"/>
31+
/// to enable Ctrl+C cancellation.
32+
/// </remarks>
33+
/// <param name="port">Local TCP port to listen on for the OAuth callback.</param>
34+
/// <param name="cancellationToken">Cancellation token to abort the listener.</param>
35+
/// <returns>The OAuth authorization code from the browser redirect.</returns>
36+
/// <exception cref="OperationCanceledException">Thrown when cancellation is requested.</exception>
37+
/// <exception cref="SafeguardDotNetException">Thrown when the redirect is malformed or missing.</exception>
38+
public static async Task<string> ListenAsync(int port, CancellationToken cancellationToken)
39+
{
40+
var tcpListener = new TcpListener(IPAddress.Loopback, port);
41+
tcpListener.Start();
42+
43+
try
44+
{
45+
TcpClient tcpClient;
46+
using (cancellationToken.Register(tcpListener.Stop))
47+
{
48+
try
49+
{
50+
tcpClient = await tcpListener.AcceptTcpClientAsync().ConfigureAwait(false);
51+
}
52+
catch (Exception ex) when ((ex is ObjectDisposedException || ex is SocketException)
53+
&& cancellationToken.IsCancellationRequested)
54+
{
55+
throw new OperationCanceledException(cancellationToken);
56+
}
57+
}
58+
59+
using (tcpClient)
60+
{
61+
using var networkStream = tcpClient.GetStream();
62+
var readBuffer = new byte[1024];
63+
var sb = new StringBuilder();
64+
do
65+
{
66+
var numberOfBytesRead = await networkStream.ReadAsync(readBuffer, 0, readBuffer.Length, cancellationToken)
67+
.ConfigureAwait(false);
68+
var s = Encoding.ASCII.GetString(readBuffer, 0, numberOfBytesRead);
69+
sb.Append(s);
70+
}
71+
while (networkStream.DataAvailable);
72+
73+
var fullResponse =
74+
"HTTP/1.1 200 OK\r\n\r\n<html><head><title>Authentication Complete</title></head><body><h2>Authentication complete.</h2>" +
75+
"<p>You can return to your application.</p><p>Feel free to close this browser tab.</p></body></html>\r\n";
76+
var response = Encoding.ASCII.GetBytes(fullResponse);
77+
await networkStream.WriteAsync(response, 0, response.Length, cancellationToken).ConfigureAwait(false);
78+
await networkStream.FlushAsync(cancellationToken).ConfigureAwait(false);
79+
80+
var httpRequest = sb.ToString();
81+
var authCode = HttpUtility.ParseQueryString(ExtractUriFromHttpRequest(httpRequest)).Get("oauth");
82+
if (string.IsNullOrEmpty(authCode))
83+
{
84+
throw new SafeguardDotNetException("OAuth callback did not contain an authorization code.");
85+
}
86+
87+
return authCode;
88+
}
89+
}
90+
finally
91+
{
92+
tcpListener.Stop();
93+
}
94+
}
95+
2096
public void Listen(int port, CancellationToken cancellationToken)
2197
{
2298
var tcpListener = new TcpListener(IPAddress.Loopback, port);

SafeguardDotNet.BrowserLogin/DefaultBrowserLogin.cs

Lines changed: 58 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ namespace OneIdentity.SafeguardDotNet.BrowserLogin;
44

55
using System;
66
using System.Threading;
7+
using System.Threading.Tasks;
78

89
using Serilog;
910

@@ -17,49 +18,83 @@ public static class DefaultBrowserLogin
1718
/// Connect to Safeguard by launching the default browser for OAuth2/PKCE authentication.
1819
/// Opens a local TCP listener to receive the authorization code callback from the browser.
1920
/// </summary>
21+
/// <remarks>
22+
/// WARNING: This method blocks indefinitely until the browser callback is received.
23+
/// If the user does not complete authentication, this call will never return.
24+
/// For programmatic cancellation, use <see cref="ConnectAsync"/> with a
25+
/// <see cref="CancellationToken"/> or
26+
/// <see cref="Safeguard.AgentBasedLoginUtils.CreateConsoleCancellationToken"/>.
27+
/// </remarks>
2028
/// <param name="appliance">Network address of Safeguard appliance</param>
2129
/// <param name="username">Optional username to pre-fill the login form</param>
2230
/// <param name="port">Local TCP port to listen for OAuth callback (default: 8400)</param>
2331
/// <param name="apiVersion">Target API version to use (default: 4)</param>
2432
/// <param name="ignoreSsl">Ignore validation of Safeguard appliance SSL certificate (default: false)</param>
2533
/// <returns>Reusable Safeguard API connection</returns>
2634
public static ISafeguardConnection Connect(
27-
string appliance, string username = "", int port = 8400, int apiVersion = Safeguard.DefaultApiVersion, bool ignoreSsl = false)
35+
string appliance,
36+
string username = "",
37+
int port = 8400,
38+
int apiVersion = Safeguard.DefaultApiVersion,
39+
bool ignoreSsl = false)
40+
{
41+
return ConnectAsync(appliance, username, port, apiVersion, ignoreSsl, CancellationToken.None)
42+
.GetAwaiter().GetResult();
43+
}
44+
45+
/// <summary>
46+
/// Connect to Safeguard by launching the default browser for OAuth2/PKCE authentication (async).
47+
/// Opens a local TCP listener to receive the authorization code callback from the browser.
48+
/// Returns when the user completes authentication or the cancellation token is triggered.
49+
/// </summary>
50+
/// <remarks>
51+
/// WARNING: This method blocks indefinitely until the browser callback is received.
52+
/// If no <paramref name="cancellationToken"/> is provided, the call will never return
53+
/// if the user does not complete authentication. Always provide a cancellation token
54+
/// with a timeout or use <see cref="Safeguard.AgentBasedLoginUtils.CreateConsoleCancellationToken"/>
55+
/// to enable Ctrl+C cancellation.
56+
/// </remarks>
57+
/// <param name="appliance">Network address of Safeguard appliance</param>
58+
/// <param name="username">Optional username to pre-fill the login form</param>
59+
/// <param name="port">Local TCP port to listen for OAuth callback (default: 8400)</param>
60+
/// <param name="apiVersion">Target API version to use (default: 4)</param>
61+
/// <param name="ignoreSsl">Ignore validation of Safeguard appliance SSL certificate (default: false)</param>
62+
/// <param name="cancellationToken">Cancellation token to abort the flow.</param>
63+
/// <returns>Reusable Safeguard API connection</returns>
64+
/// <exception cref="SafeguardDotNetException">Thrown when authentication fails or API error.</exception>
65+
/// <exception cref="OperationCanceledException">Thrown when cancellation is requested.</exception>
66+
public static async Task<ISafeguardConnection> ConnectAsync(
67+
string appliance,
68+
string username = "",
69+
int port = 8400,
70+
int apiVersion = Safeguard.DefaultApiVersion,
71+
bool ignoreSsl = false,
72+
CancellationToken cancellationToken = default)
2873
{
2974
Log.Debug("Calling RSTS for primary authentication");
3075

3176
var oauthCodeVerifier = Safeguard.AgentBasedLoginUtils.OAuthCodeVerifier();
32-
var tokenExtractor = new AuthorizationCodeExtractor();
3377
var browserLauncher = new BrowserLauncher(appliance, oauthCodeVerifier);
3478

35-
using var source = new CancellationTokenSource();
36-
Console.CancelKeyPress += (sender, e) => { source.Cancel(); };
37-
3879
browserLauncher.Show(username, port);
39-
tokenExtractor.Listen(port, source.Token);
4080

41-
if (string.IsNullOrEmpty(tokenExtractor.AuthorizationCode))
42-
{
43-
throw new SafeguardDotNetException("Unable to obtain authorization code");
44-
}
81+
var authorizationCode = await AuthorizationCodeExtractor.ListenAsync(port, cancellationToken).ConfigureAwait(false);
4582

4683
Log.Debug("Redeeming RSTS authorization code");
4784

48-
using var rstsAccessToken = Safeguard.AgentBasedLoginUtils.PostAuthorizationCodeFlow(
49-
appliance, tokenExtractor.AuthorizationCode, oauthCodeVerifier, Safeguard.AgentBasedLoginUtils.RedirectUri);
85+
using var rstsAccessToken = await Safeguard.AgentBasedLoginUtils.PostAuthorizationCodeFlowAsync(
86+
appliance,
87+
authorizationCode,
88+
oauthCodeVerifier,
89+
Safeguard.AgentBasedLoginUtils.RedirectUriTcpListener,
90+
ignoreSsl,
91+
cancellationToken)
92+
.ConfigureAwait(false);
5093

5194
Log.Debug("Exchanging RSTS access token");
5295

53-
var responseObject = Safeguard.AgentBasedLoginUtils.PostLoginResponse(appliance, rstsAccessToken, apiVersion);
54-
55-
var statusValue = responseObject.GetValue("Status")?.ToString();
56-
57-
if (string.IsNullOrEmpty(statusValue) || statusValue != "Success")
58-
{
59-
throw new SafeguardDotNetException($"Error response status {statusValue} from RSTS");
60-
}
61-
62-
using var accessToken = responseObject.GetValue("UserToken")?.ToString().ToSecureString();
63-
return Safeguard.Connect(appliance, accessToken, apiVersion, ignoreSsl);
96+
return await Safeguard.AgentBasedLoginUtils.ExchangeRstsTokenForConnectionAsync(
97+
appliance, rstsAccessToken, apiVersion, ignoreSsl, cancellationToken)
98+
.ConfigureAwait(false);
6499
}
65100
}

SafeguardDotNet.DeviceCodeLogin/DeviceCodeLogin.cs

Lines changed: 4 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ public static async Task<ISafeguardConnection> ConnectAsync(
7575
var clientId = parameters.ClientId ?? "SafeguardDotNet";
7676
var scope = parameters.Scope ?? "rsts:sts:primaryproviderid:local";
7777

78-
using var http = CreateHttpClient(ignoreSsl);
78+
using var http = Safeguard.AgentBasedLoginUtils.CreateStatelessHttpClient(ignoreSsl);
7979

8080
// Step 1: Request device code (CRITICAL: no trailing slash on URL)
8181
Log.Debug("Requesting device authorization from {Appliance}", appliance);
@@ -188,35 +188,9 @@ public static async Task<ISafeguardConnection> ConnectAsync(
188188

189189
using (rstsAccessToken)
190190
{
191-
var responseObject = Safeguard.AgentBasedLoginUtils.PostLoginResponse(
192-
appliance, rstsAccessToken, apiVersion);
193-
194-
var statusValue = responseObject.GetValue("Status")?.ToString();
195-
if (string.IsNullOrEmpty(statusValue) || statusValue != "Success")
196-
{
197-
throw new SafeguardDotNetException($"Error exchanging RSTS token, status: {statusValue}");
198-
}
199-
200-
// Step 5: Create connection
201-
using var accessToken = responseObject.GetValue("UserToken")?.ToString().ToSecureString();
202-
return Safeguard.Connect(appliance, accessToken, apiVersion, ignoreSsl);
203-
}
204-
}
205-
206-
private static HttpClient CreateHttpClient(bool ignoreSsl)
207-
{
208-
var handler = new HttpClientHandler()
209-
{
210-
SslProtocols = System.Security.Authentication.SslProtocols.Tls12,
211-
};
212-
213-
if (ignoreSsl)
214-
{
215-
#pragma warning disable S4830 // Intentional SSL bypass when user explicitly opts in
216-
handler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true;
217-
#pragma warning restore S4830
191+
return await Safeguard.AgentBasedLoginUtils.ExchangeRstsTokenForConnectionAsync(
192+
appliance, rstsAccessToken, apiVersion, ignoreSsl, cancellationToken)
193+
.ConfigureAwait(false);
218194
}
219-
220-
return new HttpClient(handler);
221195
}
222196
}

SafeguardDotNet.GuiLogin/LoginWindow.cs

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace OneIdentity.SafeguardDotNet.GuiLogin
44
{
5+
using System.Threading;
6+
57
using Serilog;
68

79
/// <summary>
@@ -33,24 +35,18 @@ public static ISafeguardConnection Connect(string appliance, int apiVersion = Sa
3335

3436
Log.Debug("Redeeming RSTS authorization code");
3537

36-
using (var rstsAccessToken = Safeguard.AgentBasedLoginUtils.PostAuthorizationCodeFlow(
37-
appliance, rstsWindow.AuthorizationCode, rstsWindow.CodeVerifier, Safeguard.AgentBasedLoginUtils.RedirectUri))
38+
using (var rstsAccessToken = Safeguard.AgentBasedLoginUtils.PostAuthorizationCodeFlowAsync(
39+
appliance,
40+
rstsWindow.AuthorizationCode,
41+
rstsWindow.CodeVerifier,
42+
Safeguard.AgentBasedLoginUtils.RedirectUri,
43+
ignoreSsl,
44+
CancellationToken.None).GetAwaiter().GetResult())
3845
{
3946
Log.Debug("Exchanging RSTS access token");
4047

41-
var responseObject = Safeguard.AgentBasedLoginUtils.PostLoginResponse(appliance, rstsAccessToken);
42-
43-
var statusValue = responseObject.GetValue("Status")?.ToString();
44-
45-
if (string.IsNullOrEmpty(statusValue) || statusValue != "Success")
46-
{
47-
throw new SafeguardDotNetException($"Error response status {statusValue} from login response service");
48-
}
49-
50-
using (var accessToken = responseObject.GetValue("UserToken")?.ToString().ToSecureString())
51-
{
52-
return Safeguard.Connect(appliance, accessToken, apiVersion, ignoreSsl);
53-
}
48+
return Safeguard.AgentBasedLoginUtils.ExchangeRstsTokenForConnection(
49+
appliance, rstsAccessToken, apiVersion, ignoreSsl);
5450
}
5551
}
5652
else

0 commit comments

Comments
 (0)