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:
@@ -5,7 +5,9 @@
|
|||||||
@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo
|
@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo
|
||||||
@inject ISessionCredentialStore CredStore
|
@inject ISessionCredentialStore CredStore
|
||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
@inject SharepointToolbox.Web.Services.OAuth.IOAuthFlowCache OAuthCache
|
@inject SharepointToolbox.Web.Services.OAuth.IEntraDeviceCodeFlow DeviceFlow
|
||||||
|
@inject SharepointToolbox.Web.Services.Auth.IAppRegistrationService AppRegService
|
||||||
|
@inject Microsoft.Extensions.Options.IOptions<SharepointToolbox.Web.Core.Config.ClientConnectOptions> ConnectOpts
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
@using Microsoft.AspNetCore.WebUtilities
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
@using SharepointToolbox.Web.Core.Models
|
@using SharepointToolbox.Web.Core.Models
|
||||||
@@ -111,14 +113,37 @@
|
|||||||
style="flex:1" />
|
style="flex:1" />
|
||||||
<button class="btn btn-secondary" @onclick="RegisterAppAsync"
|
<button class="btn btn-secondary" @onclick="RegisterAppAsync"
|
||||||
disabled="@(!CanRegister || _registering)"
|
disabled="@(!CanRegister || _registering)"
|
||||||
title="@(CanRegister ? "Register app in client Entra ID (requires Global Admin)" : "Fill Tenant URL, Tenant ID and Profile Name first")">
|
title="@(CanRegister ? "Register app in client Entra ID (requires an admin who can create app registrations)" : "Fill Tenant URL, Tenant ID and Profile Name first")">
|
||||||
@(_registering ? "Redirecting…" : "Register in Entra")
|
@(_registering ? "Waiting…" : "Register in Entra")
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
Click "Register in Entra" to auto-create the app registration in the client tenant — requires Global Admin credentials.
|
Click "Register in Entra" to auto-create the app registration in the client tenant.
|
||||||
|
You'll sign in with a client admin account — no secrets, no pre-existing app needed.
|
||||||
Or enter an existing public client App Registration ID manually.
|
Or enter an existing public client App Registration ID manually.
|
||||||
</small>
|
</small>
|
||||||
|
|
||||||
|
@if (_deviceCode is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-info" style="margin-top:10px">
|
||||||
|
<div style="margin-bottom:6px">Sign in to the <strong>client tenant</strong> to authorize app creation:</div>
|
||||||
|
<ol style="margin:0 0 8px 18px;padding:0;line-height:1.7">
|
||||||
|
<li>Open <a href="@_deviceCode.VerificationUri" target="_blank" rel="noopener">@_deviceCode.VerificationUri</a></li>
|
||||||
|
<li>Enter code:
|
||||||
|
<code style="font-size:16px;font-weight:700;letter-spacing:1px;background:#fff;padding:2px 8px;border-radius:4px;border:1px solid var(--border)">@_deviceCode.UserCode</code>
|
||||||
|
</li>
|
||||||
|
<li>Approve the requested permissions with an admin account.</li>
|
||||||
|
</ol>
|
||||||
|
<div class="flex-row" style="gap:8px">
|
||||||
|
<span class="text-muted">@_regStatus</span>
|
||||||
|
<button class="btn btn-secondary btn-sm" @onclick="CancelRegistration">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrEmpty(_regStatus))
|
||||||
|
{
|
||||||
|
<div class="text-muted" style="margin-top:8px">@_regStatus</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-row mt-8">
|
<div class="flex-row mt-8">
|
||||||
@@ -137,6 +162,17 @@
|
|||||||
private string _formError = string.Empty;
|
private string _formError = string.Empty;
|
||||||
private string _pageError = string.Empty;
|
private string _pageError = string.Empty;
|
||||||
|
|
||||||
|
private SharepointToolbox.Web.Services.OAuth.DeviceCodeStart? _deviceCode;
|
||||||
|
private string _regStatus = string.Empty;
|
||||||
|
private CancellationTokenSource? _regCts;
|
||||||
|
|
||||||
|
// Graph delegated scopes the admin must consent to so we can create the app registration.
|
||||||
|
private const string RegistrationScope =
|
||||||
|
"https://graph.microsoft.com/Application.ReadWrite.All " +
|
||||||
|
"https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All " +
|
||||||
|
"https://graph.microsoft.com/Directory.Read.All " +
|
||||||
|
"openid offline_access";
|
||||||
|
|
||||||
private bool CanRegister =>
|
private bool CanRegister =>
|
||||||
!string.IsNullOrWhiteSpace(_form.Name) &&
|
!string.IsNullOrWhiteSpace(_form.Name) &&
|
||||||
!string.IsNullOrWhiteSpace(_form.TenantUrl) &&
|
!string.IsNullOrWhiteSpace(_form.TenantUrl) &&
|
||||||
@@ -150,36 +186,9 @@
|
|||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
if (!firstRender) return;
|
if (!firstRender) return;
|
||||||
await HandleRegResultAsync();
|
|
||||||
await HandleConnectErrorAsync();
|
await HandleConnectErrorAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleRegResultAsync()
|
|
||||||
{
|
|
||||||
var uri = new Uri(Nav.Uri);
|
|
||||||
var query = QueryHelpers.ParseQuery(uri.Query);
|
|
||||||
|
|
||||||
if (!query.TryGetValue("reg_result_key", out var key) || string.IsNullOrEmpty(key))
|
|
||||||
return;
|
|
||||||
|
|
||||||
var result = OAuthCache.GetAndRemoveRegistrationResult(key!);
|
|
||||||
if (result is not null)
|
|
||||||
{
|
|
||||||
_form = new TenantProfile
|
|
||||||
{
|
|
||||||
Name = result.TenantName,
|
|
||||||
TenantUrl = result.TenantUrl,
|
|
||||||
TenantId = result.TenantId,
|
|
||||||
ClientId = result.ClientId,
|
|
||||||
};
|
|
||||||
_showForm = true;
|
|
||||||
_formError = string.Empty;
|
|
||||||
await InvokeAsync(StateHasChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
Nav.NavigateTo(uri.GetLeftPart(UriPartial.Path), replace: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleConnectErrorAsync()
|
private async Task HandleConnectErrorAsync()
|
||||||
{
|
{
|
||||||
var uri = new Uri(Nav.Uri);
|
var uri = new Uri(Nav.Uri);
|
||||||
@@ -220,21 +229,61 @@
|
|||||||
|
|
||||||
private async Task RegisterAppAsync()
|
private async Task RegisterAppAsync()
|
||||||
{
|
{
|
||||||
if (!CanRegister) return;
|
if (!CanRegister || _registering) return;
|
||||||
|
|
||||||
_registering = true;
|
_registering = true;
|
||||||
|
_formError = string.Empty;
|
||||||
|
_regStatus = "Requesting a sign-in code…";
|
||||||
|
_deviceCode = null;
|
||||||
|
_regCts = new CancellationTokenSource(TimeSpan.FromMinutes(15));
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
|
|
||||||
var returnUrl = Nav.Uri.Contains('?')
|
try
|
||||||
? Nav.Uri.Substring(0, Nav.Uri.IndexOf('?'))
|
{
|
||||||
: Nav.Uri;
|
// Secretless bootstrap: device code flow against the client tenant.
|
||||||
|
_deviceCode = await DeviceFlow.BeginAsync(_form.TenantId.Trim(), RegistrationScope, _regCts.Token);
|
||||||
|
_regStatus = "Waiting for sign-in to complete…";
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
var url = $"/connect/register-initiate" +
|
var adminToken = await DeviceFlow.PollForAccessTokenAsync(_form.TenantId.Trim(), _deviceCode, _regCts.Token);
|
||||||
$"?tenantId={Uri.EscapeDataString(_form.TenantId)}" +
|
|
||||||
$"&tenantName={Uri.EscapeDataString(_form.Name)}" +
|
|
||||||
$"&tenantUrl={Uri.EscapeDataString(_form.TenantUrl)}" +
|
|
||||||
$"&returnUrl={Uri.EscapeDataString(returnUrl)}";
|
|
||||||
|
|
||||||
Nav.NavigateTo(url, forceLoad: true);
|
_deviceCode = null;
|
||||||
|
_regStatus = "Creating the app registration…";
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
var clientId = await AppRegService.CreateAsync(
|
||||||
|
adminAccessToken: adminToken,
|
||||||
|
tenantName: _form.Name,
|
||||||
|
redirectUri: ConnectOpts.Value.RedirectUri,
|
||||||
|
ct: _regCts.Token);
|
||||||
|
|
||||||
|
_form.ClientId = clientId;
|
||||||
|
_regStatus = "App registered. Review and Save the profile.";
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_regStatus = "Registration cancelled.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_formError = $"Registration failed: {ex.Message}";
|
||||||
|
_regStatus = string.Empty;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_deviceCode = null;
|
||||||
|
_registering = false;
|
||||||
|
_regCts?.Dispose();
|
||||||
|
_regCts = null;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelRegistration()
|
||||||
|
{
|
||||||
|
_regCts?.Cancel();
|
||||||
|
_deviceCode = null;
|
||||||
|
_regStatus = "Registration cancelled.";
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SaveProfile()
|
private async Task SaveProfile()
|
||||||
|
|||||||
@@ -58,52 +58,9 @@ public static class OAuthEndpoints
|
|||||||
return Results.Redirect(authUrl);
|
return Results.Redirect(authUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Register: initiate admin auth to create app registration in client tenant
|
// ── Connect callback: exchange the auth code for the client-tenant session ─
|
||||||
app.MapGet("/connect/register-initiate", (
|
// (App registration uses a separate secretless device-code flow — see
|
||||||
HttpContext ctx,
|
// EntraDeviceCodeFlow — and does not pass through this endpoint.)
|
||||||
string tenantId,
|
|
||||||
string tenantName,
|
|
||||||
string tenantUrl,
|
|
||||||
string? returnUrl,
|
|
||||||
IOAuthFlowCache flowCache,
|
|
||||||
IOptions<ClientConnectOptions> opts,
|
|
||||||
IConfiguration config) =>
|
|
||||||
{
|
|
||||||
if (!ctx.User.Identity?.IsAuthenticated ?? true)
|
|
||||||
return Results.Unauthorized();
|
|
||||||
|
|
||||||
var o = opts.Value;
|
|
||||||
if (string.IsNullOrEmpty(o.RedirectUri))
|
|
||||||
return Results.Problem("ClientConnect:RedirectUri is not configured on this server.");
|
|
||||||
|
|
||||||
// Use our OIDC app (confidential client) to authenticate against the client tenant
|
|
||||||
var oidcClientId = config["Oidc:ClientId"];
|
|
||||||
if (string.IsNullOrEmpty(oidcClientId))
|
|
||||||
return Results.Problem("Oidc:ClientId is not configured.");
|
|
||||||
|
|
||||||
// Need admin consent for Application.ReadWrite.All + DelegatedPermissionGrant.ReadWrite.All
|
|
||||||
var (_, authUrl) = BuildAuthUrl(
|
|
||||||
tenantId: tenantId,
|
|
||||||
clientId: oidcClientId,
|
|
||||||
redirectUri: o.RedirectUri,
|
|
||||||
scope: "https://graph.microsoft.com/Application.ReadWrite.All " +
|
|
||||||
"https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All " +
|
|
||||||
"openid offline_access",
|
|
||||||
flowCache: flowCache,
|
|
||||||
flowState: new OAuthFlowState
|
|
||||||
{
|
|
||||||
TenantId = tenantId,
|
|
||||||
TenantName = tenantName,
|
|
||||||
TenantUrl = tenantUrl,
|
|
||||||
ReturnUrl = string.IsNullOrEmpty(returnUrl) ? "/profiles" : returnUrl,
|
|
||||||
IsRegistration = true,
|
|
||||||
},
|
|
||||||
promptConsent: true);
|
|
||||||
|
|
||||||
return Results.Redirect(authUrl);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Shared callback for both connect and register flows ────────────────────
|
|
||||||
app.MapGet("/connect/callback", async (
|
app.MapGet("/connect/callback", async (
|
||||||
string? code,
|
string? code,
|
||||||
string? state,
|
string? state,
|
||||||
@@ -111,8 +68,6 @@ public static class OAuthEndpoints
|
|||||||
string? error_description,
|
string? error_description,
|
||||||
IOAuthFlowCache flowCache,
|
IOAuthFlowCache flowCache,
|
||||||
IOptions<ClientConnectOptions> opts,
|
IOptions<ClientConnectOptions> opts,
|
||||||
IConfiguration config,
|
|
||||||
IAppRegistrationService appRegService,
|
|
||||||
IHttpClientFactory httpClientFactory) =>
|
IHttpClientFactory httpClientFactory) =>
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(error))
|
if (!string.IsNullOrEmpty(error))
|
||||||
@@ -131,66 +86,7 @@ public static class OAuthEndpoints
|
|||||||
var o = opts.Value;
|
var o = opts.Value;
|
||||||
var http = httpClientFactory.CreateClient("oauth");
|
var http = httpClientFactory.CreateClient("oauth");
|
||||||
|
|
||||||
if (flowState.IsRegistration)
|
// Connect flow: public client exchange (profile ClientId, PKCE, no secret).
|
||||||
{
|
|
||||||
// ── Registration flow: public client exchange (PKCE only, no secret) ──
|
|
||||||
var oidcClientId = config["Oidc:ClientId"]!;
|
|
||||||
|
|
||||||
var body = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["grant_type"] = "authorization_code",
|
|
||||||
["client_id"] = oidcClientId,
|
|
||||||
["code"] = code,
|
|
||||||
["redirect_uri"] = o.RedirectUri,
|
|
||||||
["code_verifier"] = flowState.CodeVerifier,
|
|
||||||
["scope"] = "https://graph.microsoft.com/Application.ReadWrite.All " +
|
|
||||||
"https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All " +
|
|
||||||
"openid offline_access",
|
|
||||||
};
|
|
||||||
|
|
||||||
var tokenUrl = $"https://login.microsoftonline.com/{flowState.TenantId}/oauth2/v2.0/token";
|
|
||||||
var resp = await http.PostAsync(tokenUrl, new FormUrlEncodedContent(body));
|
|
||||||
var json = await resp.Content.ReadAsStringAsync();
|
|
||||||
|
|
||||||
if (!resp.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var msg = Uri.EscapeDataString($"Admin token exchange failed: {json}");
|
|
||||||
return Results.Redirect($"/profiles?connect_error={msg}");
|
|
||||||
}
|
|
||||||
|
|
||||||
using var doc = JsonDocument.Parse(json);
|
|
||||||
var accessToken = doc.RootElement.GetProperty("access_token").GetString()!;
|
|
||||||
|
|
||||||
string clientId;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
clientId = await appRegService.CreateAsync(
|
|
||||||
adminAccessToken: accessToken,
|
|
||||||
tenantName: flowState.TenantName,
|
|
||||||
redirectUri: o.RedirectUri);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
var msg = Uri.EscapeDataString($"App registration failed: {ex.Message}");
|
|
||||||
return Results.Redirect($"/profiles?connect_error={msg}");
|
|
||||||
}
|
|
||||||
|
|
||||||
var regKey = Guid.NewGuid().ToString("N");
|
|
||||||
flowCache.StoreRegistrationResult(regKey, new AppRegistrationResult
|
|
||||||
{
|
|
||||||
ClientId = clientId,
|
|
||||||
TenantId = flowState.TenantId,
|
|
||||||
TenantUrl = flowState.TenantUrl,
|
|
||||||
TenantName = flowState.TenantName,
|
|
||||||
DisplayName = $"SP Toolbox — {flowState.TenantName}",
|
|
||||||
});
|
|
||||||
|
|
||||||
var returnTo = QueryHelpers.AddQueryString(flowState.ReturnUrl, "reg_result_key", regKey);
|
|
||||||
return Results.Redirect(returnTo);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// ── Connect flow: public client exchange (profile ClientId, no secret) ──
|
|
||||||
var body = new Dictionary<string, string>
|
var body = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["grant_type"] = "authorization_code",
|
["grant_type"] = "authorization_code",
|
||||||
@@ -230,7 +126,6 @@ public static class OAuthEndpoints
|
|||||||
|
|
||||||
var returnTo = QueryHelpers.AddQueryString(flowState.ReturnUrl, "token_key", tokenKey);
|
var returnTo = QueryHelpers.AddQueryString(flowState.ReturnUrl, "token_key", tokenKey);
|
||||||
return Results.Redirect(returnTo);
|
return Results.Redirect(returnTo);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ builder.Services.AddSingleton(new AuditRepository(Path.Combine(dataFolder, "audi
|
|||||||
// ── Auth infrastructure ───────────────────────────────────────────────────────
|
// ── Auth infrastructure ───────────────────────────────────────────────────────
|
||||||
builder.Services.AddSingleton<IUserService, UserService>();
|
builder.Services.AddSingleton<IUserService, UserService>();
|
||||||
builder.Services.AddSingleton<IOAuthFlowCache, OAuthFlowCache>();
|
builder.Services.AddSingleton<IOAuthFlowCache, OAuthFlowCache>();
|
||||||
|
builder.Services.AddSingleton<IEntraDeviceCodeFlow, EntraDeviceCodeFlow>();
|
||||||
builder.Services.AddHttpClient<ITokenRefreshService, TokenRefreshService>();
|
builder.Services.AddHttpClient<ITokenRefreshService, TokenRefreshService>();
|
||||||
builder.Services.AddHttpClient<IAppRegistrationService, AppRegistrationService>();
|
builder.Services.AddHttpClient<IAppRegistrationService, AppRegistrationService>();
|
||||||
builder.Services.AddScoped<GraphClientFactory>();
|
builder.Services.AddScoped<GraphClientFactory>();
|
||||||
|
|||||||
@@ -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