Skip to content

Commit 5564b76

Browse files
petrsndCopilot
andauthored
Add OAuth 2.0 Device Authorization Grant (RFC 8628) support (#229)
* docs: add verified device code flow implementation plan Comprehensive implementation plan for OAuth 2.0 Device Authorization Grant (RFC 8628) as requested in GitHub issue #226. Plan includes: - Verified RSTS API specification (tested against SPP 8.2.0.21662) - Complete implementation pseudocode ready for agent execution - Project/csproj templates based on existing login packages - Status tracking table for background agent progress - Way of working section defining execution rules and gates - Critical gotchas (trailing slash 405, JSON body format) - 18 unit test cases and integration test scenarios Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add DeviceCodeLogin project * Add DeviceCodeInfo DTO class * Add DeviceCodeLoginParameters class and document filel boilerplate in AGENTS.md * Implement DeviceCodeLogin static class with Connect and ConnectAsync * Add readme * Add test program for device code * add integration test suite * Fix network error handling and integration test suite issues * Remove the interactive test ... it doesn't work because of swallowed output * Improve integration tests with proper grant type toggling * Packaging and build integration for new device code library * Bump prerelease version to 8.3.0 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 046e0f7 commit 5564b76

14 files changed

Lines changed: 1679 additions & 1 deletion

DEVICE-CODE-PLAN.md

Lines changed: 995 additions & 0 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ enabled this grant type, calls using this method will fail.
134134
| Package | NuGet | Use Case |
135135
|-|-|-|
136136
| [SafeguardDotNet.BrowserLogin](SafeguardDotNet.BrowserLogin) | `OneIdentity.SafeguardDotNet.BrowserLogin` | Interactive apps — opens the default browser for login (cross-platform) |
137+
| [SafeguardDotNet.DeviceCodeLogin](SafeguardDotNet.DeviceCodeLogin) | `OneIdentity.SafeguardDotNet.DeviceCodeLogin` | Headless/remote devices — displays a URL and code for the user to authenticate on another device |
137138
| [SafeguardDotNet.PkceNoninteractiveLogin](SafeguardDotNet.PkceNoninteractiveLogin) | `OneIdentity.SafeguardDotNet.PkceNoninteractiveLogin` | Automated/programmatic scenarios — no browser required |
138139
| [SafeguardDotNet.GuiLogin](SafeguardDotNet.GuiLogin) | `OneIdentity.SafeguardDotNet.GuiLogin` | Windows Forms desktop apps — embedded WebView2 dialog |
139140

@@ -163,6 +164,32 @@ var connection = DefaultBrowserLogin.Connect("safeguard.sample.corp");
163164
Console.WriteLine(connection.InvokeMethod(Service.Core, Method.Get, "Me"));
164165
```
165166

167+
### Device Code Login (Recommended for Headless/Remote Devices)
168+
169+
When there is no browser on the local machine (SSH sessions, containers, IoT
170+
devices), use the Device Code flow. The user authenticates on a separate device:
171+
172+
```PowerShell
173+
PS> dotnet add package OneIdentity.SafeguardDotNet.DeviceCodeLogin
174+
```
175+
176+
```C#
177+
using OneIdentity.SafeguardDotNet;
178+
using OneIdentity.SafeguardDotNet.DeviceCodeLogin;
179+
180+
var connection = DeviceCodeLogin.Connect("safeguard.sample.corp",
181+
new DeviceCodeLoginParameters
182+
{
183+
DisplayCallback = info =>
184+
{
185+
Console.WriteLine($"Visit: {info.VerificationUri}");
186+
Console.WriteLine($"Enter code: {info.UserCode}");
187+
},
188+
},
189+
ignoreSsl: true);
190+
Console.WriteLine(connection.InvokeMethod(Service.Core, Method.Get, "Me"));
191+
```
192+
166193
### PKCE Non-Interactive Login (Recommended for Automation)
167194

168195
When you need programmatic authentication without a browser (e.g. automated

SafeguardDotNet.Core.sln

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SafeguardDotNetPkceNoninter
2929
EndProject
3030
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SampleA2aService", "Samples\SampleA2aService\SampleA2aService.csproj", "{0149D659-78E3-476B-81F1-A80BDFA6F8A9}"
3131
EndProject
32+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SafeguardDotNet.DeviceCodeLogin", "SafeguardDotNet.DeviceCodeLogin\SafeguardDotNet.DeviceCodeLogin.csproj", "{6802F7A9-CD3D-4608-9EEC-C98636DA2708}"
33+
EndProject
34+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SafeguardDotNetDeviceCodeLoginTester", "Test\SafeguardDotNetDeviceCodeLoginTester\SafeguardDotNetDeviceCodeLoginTester.csproj", "{20D9ED51-6852-44FC-A413-5EC7631139F9}"
35+
EndProject
3236
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Test", "Test", "{DD89D86B-68DA-4EB0-8EBC-60DE3DC2B084}"
3337
EndProject
3438
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{867EB36D-7893-444D-900D-29733E8E2636}"
@@ -108,6 +112,14 @@ Global
108112
{1B11B367-2F76-49EE-A820-2A0A8B1721B1}.Debug|Any CPU.Build.0 = Debug|Any CPU
109113
{1B11B367-2F76-49EE-A820-2A0A8B1721B1}.Release|Any CPU.ActiveCfg = Release|Any CPU
110114
{1B11B367-2F76-49EE-A820-2A0A8B1721B1}.Release|Any CPU.Build.0 = Release|Any CPU
115+
{6802F7A9-CD3D-4608-9EEC-C98636DA2708}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
116+
{6802F7A9-CD3D-4608-9EEC-C98636DA2708}.Debug|Any CPU.Build.0 = Debug|Any CPU
117+
{6802F7A9-CD3D-4608-9EEC-C98636DA2708}.Release|Any CPU.ActiveCfg = Release|Any CPU
118+
{6802F7A9-CD3D-4608-9EEC-C98636DA2708}.Release|Any CPU.Build.0 = Release|Any CPU
119+
{20D9ED51-6852-44FC-A413-5EC7631139F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
120+
{20D9ED51-6852-44FC-A413-5EC7631139F9}.Debug|Any CPU.Build.0 = Debug|Any CPU
121+
{20D9ED51-6852-44FC-A413-5EC7631139F9}.Release|Any CPU.ActiveCfg = Release|Any CPU
122+
{20D9ED51-6852-44FC-A413-5EC7631139F9}.Release|Any CPU.Build.0 = Release|Any CPU
111123
EndGlobalSection
112124
GlobalSection(SolutionProperties) = preSolution
113125
HideSolutionNode = FALSE
@@ -122,6 +134,7 @@ Global
122134
{D0009DBA-16E1-4E6F-AC1E-D11B16AB3C41} = {DD89D86B-68DA-4EB0-8EBC-60DE3DC2B084}
123135
{C6D34567-3456-4321-9876-345678901CDE} = {DD89D86B-68DA-4EB0-8EBC-60DE3DC2B084}
124136
{0149D659-78E3-476B-81F1-A80BDFA6F8A9} = {867EB36D-7893-444D-900D-29733E8E2636}
137+
{20D9ED51-6852-44FC-A413-5EC7631139F9} = {DD89D86B-68DA-4EB0-8EBC-60DE3DC2B084}
125138
{1EB6D64D-7AFB-41DC-B11B-58934D053684} = {9249E337-656D-4970-B45C-7A077C56FA44}
126139
{1B11B367-2F76-49EE-A820-2A0A8B1721B1} = {DD89D86B-68DA-4EB0-8EBC-60DE3DC2B084}
127140
EndGlobalSection
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright (c) One Identity LLC. All rights reserved.
2+
3+
namespace OneIdentity.SafeguardDotNet.DeviceCodeLogin;
4+
5+
/// <summary>
6+
/// Contains the device authorization response information displayed to the user.
7+
/// </summary>
8+
public class DeviceCodeInfo
9+
{
10+
/// <summary>The URI the user must visit to authenticate (for manual code entry).</summary>
11+
public string VerificationUri { get; set; }
12+
13+
/// <summary>The code the user must enter at the verification URI. Format: XXX-XXX-XXX.</summary>
14+
public string UserCode { get; set; }
15+
16+
/// <summary>
17+
/// Complete URI with the user code pre-filled. The user can simply click/open this
18+
/// URL without manually typing the code. Always provided by Safeguard RSTS.
19+
/// </summary>
20+
public string VerificationUriComplete { get; set; }
21+
22+
/// <summary>Lifetime in seconds before the codes expire (always 300 from RSTS).</summary>
23+
public int ExpiresIn { get; set; }
24+
}
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
// Copyright (c) One Identity LLC. All rights reserved.
2+
3+
namespace OneIdentity.SafeguardDotNet.DeviceCodeLogin;
4+
5+
using System;
6+
using System.Net.Http;
7+
using System.Security;
8+
using System.Text;
9+
using System.Threading;
10+
using System.Threading.Tasks;
11+
12+
using Newtonsoft.Json;
13+
using Newtonsoft.Json.Linq;
14+
15+
using Serilog;
16+
17+
/// <summary>
18+
/// Provides device code-based authentication to Safeguard using OAuth 2.0
19+
/// Device Authorization Grant (RFC 8628).
20+
/// </summary>
21+
public static class DeviceCodeLogin
22+
{
23+
/// <summary>
24+
/// Connect to Safeguard API using the Device Authorization Grant.
25+
/// Blocks until the user completes authentication or the code expires.
26+
/// </summary>
27+
/// <param name="appliance">Network address of the Safeguard appliance.</param>
28+
/// <param name="parameters">Device code flow parameters including the display callback.</param>
29+
/// <param name="apiVersion">Target API version to use.</param>
30+
/// <param name="ignoreSsl">Ignore server certificate validation (dev only).</param>
31+
/// <returns>Reusable Safeguard API connection.</returns>
32+
/// <exception cref="ArgumentException">Thrown when DisplayCallback is null or appliance is empty.</exception>
33+
/// <exception cref="SafeguardDotNetException">Thrown when authentication fails, code expires, or API error.</exception>
34+
public static ISafeguardConnection Connect(
35+
string appliance,
36+
DeviceCodeLoginParameters parameters,
37+
int apiVersion = Safeguard.DefaultApiVersion,
38+
bool ignoreSsl = false)
39+
{
40+
return ConnectAsync(appliance, parameters, apiVersion, ignoreSsl, CancellationToken.None)
41+
.GetAwaiter().GetResult();
42+
}
43+
44+
/// <summary>
45+
/// Connect to Safeguard API using the Device Authorization Grant (async).
46+
/// Returns when the user completes authentication, the code expires,
47+
/// or the cancellation token is triggered.
48+
/// </summary>
49+
/// <param name="appliance">Network address of the Safeguard appliance.</param>
50+
/// <param name="parameters">Device code flow parameters including the display callback.</param>
51+
/// <param name="apiVersion">Target API version to use.</param>
52+
/// <param name="ignoreSsl">Ignore server certificate validation (dev only).</param>
53+
/// <param name="cancellationToken">Cancellation token to abort the flow.</param>
54+
/// <returns>Reusable Safeguard API connection.</returns>
55+
/// <exception cref="ArgumentException">Thrown when DisplayCallback is null or appliance is empty.</exception>
56+
/// <exception cref="SafeguardDotNetException">Thrown when authentication fails, code expires, or API error.</exception>
57+
/// <exception cref="OperationCanceledException">Thrown when cancellation is requested.</exception>
58+
public static async Task<ISafeguardConnection> ConnectAsync(
59+
string appliance,
60+
DeviceCodeLoginParameters parameters,
61+
int apiVersion = Safeguard.DefaultApiVersion,
62+
bool ignoreSsl = false,
63+
CancellationToken cancellationToken = default)
64+
{
65+
if (string.IsNullOrEmpty(appliance))
66+
{
67+
throw new ArgumentException("Appliance network address is required.", nameof(appliance));
68+
}
69+
70+
if (parameters?.DisplayCallback == null)
71+
{
72+
throw new ArgumentException("DisplayCallback is required.", nameof(parameters));
73+
}
74+
75+
var clientId = parameters.ClientId ?? "SafeguardDotNet";
76+
var scope = parameters.Scope ?? "rsts:sts:primaryproviderid:local";
77+
78+
using var http = CreateHttpClient(ignoreSsl);
79+
80+
// Step 1: Request device code (CRITICAL: no trailing slash on URL)
81+
Log.Debug("Requesting device authorization from {Appliance}", appliance);
82+
83+
var deviceAuthUrl = $"https://{appliance}/RSTS/oauth2/DeviceLogin";
84+
var requestBody = JsonConvert.SerializeObject(new { client_id = clientId, scope });
85+
var content = new StringContent(requestBody, Encoding.UTF8, "application/json");
86+
87+
HttpResponseMessage response;
88+
try
89+
{
90+
response = await http.PostAsync(deviceAuthUrl, content, cancellationToken).ConfigureAwait(false);
91+
}
92+
catch (HttpRequestException ex)
93+
{
94+
throw new SafeguardDotNetException(
95+
$"Device authorization request failed: unable to connect to {appliance}{ex.Message}", ex);
96+
}
97+
98+
var responseBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
99+
100+
if (!response.IsSuccessStatusCode)
101+
{
102+
throw new SafeguardDotNetException(
103+
$"Device authorization request failed: {response.StatusCode} {responseBody}",
104+
response.StatusCode,
105+
responseBody);
106+
}
107+
108+
var deviceResponse = JObject.Parse(responseBody);
109+
var deviceCode = deviceResponse["device_code"]?.ToString();
110+
var userCode = deviceResponse["user_code"]?.ToString();
111+
var verificationUri = deviceResponse["verification_uri"]?.ToString();
112+
var verificationUriComplete = deviceResponse["verification_uri_complete"]?.ToString();
113+
var expiresIn = deviceResponse["expires_in"]?.Value<int>() ?? 300;
114+
115+
// Step 2: Display to user via callback
116+
parameters.DisplayCallback(new DeviceCodeInfo
117+
{
118+
VerificationUri = verificationUri,
119+
UserCode = userCode,
120+
VerificationUriComplete = verificationUriComplete,
121+
ExpiresIn = expiresIn,
122+
});
123+
124+
// Step 3: Poll token endpoint
125+
Log.Debug("Polling token endpoint for device code redemption");
126+
127+
var tokenUrl = $"https://{appliance}/RSTS/oauth2/token";
128+
var intervalSeconds = parameters.PollingIntervalSeconds > 0 ? parameters.PollingIntervalSeconds : 5;
129+
var deadline = DateTime.UtcNow.AddSeconds(expiresIn);
130+
SecureString rstsAccessToken = null;
131+
132+
while (DateTime.UtcNow < deadline)
133+
{
134+
cancellationToken.ThrowIfCancellationRequested();
135+
136+
await Task.Delay(TimeSpan.FromSeconds(intervalSeconds), cancellationToken).ConfigureAwait(false);
137+
138+
var pollBody = JsonConvert.SerializeObject(new
139+
{
140+
grant_type = "urn:ietf:params:oauth:grant-type:device_code",
141+
device_code = deviceCode,
142+
client_id = clientId,
143+
});
144+
var pollContent = new StringContent(pollBody, Encoding.UTF8, "application/json");
145+
var pollResponse = await http.PostAsync(tokenUrl, pollContent, cancellationToken).ConfigureAwait(false);
146+
var pollResponseBody = await pollResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
147+
var pollJson = JObject.Parse(pollResponseBody);
148+
149+
if (pollResponse.IsSuccessStatusCode)
150+
{
151+
rstsAccessToken = pollJson["access_token"]?.ToString().ToSecureString();
152+
break;
153+
}
154+
155+
var error = pollJson["error"]?.ToString();
156+
switch (error)
157+
{
158+
case "authorization_pending":
159+
continue;
160+
case "slow_down":
161+
intervalSeconds += 5;
162+
continue;
163+
case "access_denied":
164+
throw new SafeguardDotNetException(
165+
"Device code authentication was denied.",
166+
pollResponse.StatusCode,
167+
pollResponseBody);
168+
case "expired_token":
169+
throw new SafeguardDotNetException(
170+
"Device code has expired. Please try again.",
171+
pollResponse.StatusCode,
172+
pollResponseBody);
173+
default:
174+
throw new SafeguardDotNetException(
175+
$"Unexpected error during device code polling: {error}",
176+
pollResponse.StatusCode,
177+
pollResponseBody);
178+
}
179+
}
180+
181+
if (rstsAccessToken == null)
182+
{
183+
throw new SafeguardDotNetException("Device code expired before user authenticated.");
184+
}
185+
186+
// Step 4: Exchange RSTS token for Safeguard UserToken
187+
Log.Debug("Exchanging RSTS access token for Safeguard user token");
188+
189+
using (rstsAccessToken)
190+
{
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
218+
}
219+
220+
return new HttpClient(handler);
221+
}
222+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (c) One Identity LLC. All rights reserved.
2+
3+
namespace OneIdentity.SafeguardDotNet.DeviceCodeLogin;
4+
5+
using System;
6+
7+
/// <summary>
8+
/// Parameters for the Device Code authentication flow.
9+
/// </summary>
10+
public class DeviceCodeLoginParameters
11+
{
12+
/// <summary>
13+
/// Callback invoked when the user must visit a URL and enter a code.
14+
/// The calling application is responsible for displaying this information
15+
/// to the user (e.g., Console.WriteLine, GUI dialog, etc.).
16+
/// This callback is required; passing null will throw ArgumentException.
17+
/// </summary>
18+
public Action<DeviceCodeInfo> DisplayCallback { get; set; }
19+
20+
/// <summary>
21+
/// Optional identity provider scope. Format: "rsts:sts:primaryproviderid:{provider}".
22+
/// If not specified, defaults to "rsts:sts:primaryproviderid:local".
23+
/// Note: RSTS accepts but does not functionally use this value for device code flow.
24+
/// </summary>
25+
public string Scope { get; set; }
26+
27+
/// <summary>
28+
/// OAuth2 client identifier for the device authorization request.
29+
/// Default: "SafeguardDotNet". Only change if the appliance has
30+
/// RelyingPartyApplications configured with a specific client_id.
31+
/// </summary>
32+
public string ClientId { get; set; } = "SafeguardDotNet";
33+
34+
/// <summary>
35+
/// Polling interval in seconds between token requests. Default: 5 (RFC 8628 default).
36+
/// Will be automatically increased if the server returns "slow_down".
37+
/// </summary>
38+
public int PollingIntervalSeconds { get; set; } = 5;
39+
}

0 commit comments

Comments
 (0)