Register Entra app via secretless device-code bootstrap
AADSTS700016 came from the register flow sending the configured Oidc:ClientId (still a placeholder) as the auth client. The desktop reference app never needs config: it bootstraps with the first-party "Microsoft Graph Command Line Tools" public client (14d82eec-...) via MSAL interactive, which exists in every tenant. Replicate that for the web app. A server can't do MSAL loopback and the bootstrap client's redirect URIs don't include /connect/callback, so use the OAuth 2.0 device authorization grant instead — the web-equivalent of the desktop interactive flow: - Add EntraDeviceCodeFlow: POST /devicecode then poll /token with the bootstrap client. No backing app, no client id/secret, no redirect URI. - Profiles "Register in Entra" now shows the verification URL + user code and polls until the admin signs in, then calls AppRegistrationService to create the per-client app and adopts its appId. - Remove the dead /connect/register-initiate endpoint and the IsRegistration branch from the callback (connect flow only now). The client-tenant register/connect flows are now fully secretless. The Oidc:* config is used only by the toolbox's own sign-in (unchanged). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SharepointToolbox.Web.Services.OAuth;
|
||||
|
||||
/// <summary>Result of initiating a device code flow — shown to the admin.</summary>
|
||||
public record DeviceCodeStart(
|
||||
string DeviceCode,
|
||||
string UserCode,
|
||||
string VerificationUri,
|
||||
int Interval,
|
||||
int ExpiresIn,
|
||||
string Message);
|
||||
|
||||
public interface IEntraDeviceCodeFlow
|
||||
{
|
||||
/// <summary>Begins a device code flow against the given tenant. Returns the user code + verification URI to display.</summary>
|
||||
Task<DeviceCodeStart> BeginAsync(string tenantId, string scope, CancellationToken ct);
|
||||
|
||||
/// <summary>Polls the token endpoint until the admin completes sign-in. Returns the Graph access token.</summary>
|
||||
Task<string> PollForAccessTokenAsync(string tenantId, DeviceCodeStart start, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<DeviceCodeStart> 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<string, string>
|
||||
{
|
||||
["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<string> 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<string, string>
|
||||
{
|
||||
["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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user