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}"); } } } }