using System.Text.Json;
namespace SharepointToolbox.Web.Services.OAuth;
/// Result of initiating a device code flow — shown to the admin.
public record DeviceCodeStart(
string DeviceCode,
string UserCode,
string VerificationUri,
int Interval,
int ExpiresIn,
string Message);
public interface IEntraDeviceCodeFlow
{
/// Begins a device code flow against the given tenant. Returns the user code + verification URI to display.
Task BeginAsync(string tenantId, string scope, CancellationToken ct);
/// Polls the token endpoint until the admin completes sign-in. Returns the Graph access token.
Task PollForAccessTokenAsync(string tenantId, DeviceCodeStart start, CancellationToken ct);
}
///
/// Secretless admin authentication for app registration. Uses the first-party
/// "Microsoft Graph Command Line Tools" public client (present in every tenant)
/// via the OAuth 2.0 device authorization grant — no backing app, no client
/// secret, no redirect URI. This mirrors the desktop app's MSAL interactive
/// bootstrap, adapted for a server-side web app where loopback isn't available.
///
public class EntraDeviceCodeFlow : IEntraDeviceCodeFlow
{
// Microsoft Graph Command Line Tools — well-known multi-tenant public client.
private const string BootstrapClientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e";
private readonly IHttpClientFactory _httpFactory;
public EntraDeviceCodeFlow(IHttpClientFactory httpFactory) => _httpFactory = httpFactory;
public async Task BeginAsync(string tenantId, string scope, CancellationToken ct)
{
var http = _httpFactory.CreateClient("oauth");
var url = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/devicecode";
var resp = await http.PostAsync(url, new FormUrlEncodedContent(new Dictionary
{
["client_id"] = BootstrapClientId,
["scope"] = scope,
}), ct);
var json = await resp.Content.ReadAsStringAsync(ct);
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"Failed to start device code flow: {json}");
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
return new DeviceCodeStart(
DeviceCode: root.GetProperty("device_code").GetString()!,
UserCode: root.GetProperty("user_code").GetString()!,
VerificationUri: root.GetProperty("verification_uri").GetString()!,
Interval: root.TryGetProperty("interval", out var iv) ? iv.GetInt32() : 5,
ExpiresIn: root.TryGetProperty("expires_in", out var ex) ? ex.GetInt32() : 900,
Message: root.TryGetProperty("message", out var m) ? m.GetString() ?? "" : "");
}
public async Task PollForAccessTokenAsync(string tenantId, DeviceCodeStart start, CancellationToken ct)
{
var http = _httpFactory.CreateClient("oauth");
var url = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token";
var interval = TimeSpan.FromSeconds(Math.Max(1, start.Interval));
var deadline = DateTimeOffset.UtcNow.AddSeconds(start.ExpiresIn);
while (true)
{
ct.ThrowIfCancellationRequested();
await Task.Delay(interval, ct);
if (DateTimeOffset.UtcNow > deadline)
throw new TimeoutException("Sign-in timed out before it was completed.");
var resp = await http.PostAsync(url, new FormUrlEncodedContent(new Dictionary
{
["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code",
["client_id"] = BootstrapClientId,
["device_code"] = start.DeviceCode,
}), ct);
var json = await resp.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (resp.IsSuccessStatusCode)
return root.GetProperty("access_token").GetString()!;
var error = root.TryGetProperty("error", out var e) ? e.GetString() : null;
switch (error)
{
case "authorization_pending":
continue; // user hasn't finished yet
case "slow_down":
interval += TimeSpan.FromSeconds(5); // back off as instructed
continue;
case "authorization_declined":
throw new InvalidOperationException("Sign-in was declined.");
case "expired_token":
throw new TimeoutException("The code expired before sign-in completed.");
default:
var desc = root.TryGetProperty("error_description", out var d) ? d.GetString() : json;
throw new InvalidOperationException($"Device code sign-in failed: {desc}");
}
}
}
}