|
| 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 | +} |
0 commit comments