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 ISessionCredentialStore CredStore
|
||||
@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
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using SharepointToolbox.Web.Core.Models
|
||||
@@ -111,14 +113,37 @@
|
||||
style="flex:1" />
|
||||
<button class="btn btn-secondary" @onclick="RegisterAppAsync"
|
||||
disabled="@(!CanRegister || _registering)"
|
||||
title="@(CanRegister ? "Register app in client Entra ID (requires Global Admin)" : "Fill Tenant URL, Tenant ID and Profile Name first")">
|
||||
@(_registering ? "Redirecting…" : "Register in Entra")
|
||||
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 ? "Waiting…" : "Register in Entra")
|
||||
</button>
|
||||
</div>
|
||||
<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.
|
||||
</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 class="flex-row mt-8">
|
||||
@@ -137,6 +162,17 @@
|
||||
private string _formError = 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 =>
|
||||
!string.IsNullOrWhiteSpace(_form.Name) &&
|
||||
!string.IsNullOrWhiteSpace(_form.TenantUrl) &&
|
||||
@@ -150,36 +186,9 @@
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender) return;
|
||||
await HandleRegResultAsync();
|
||||
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()
|
||||
{
|
||||
var uri = new Uri(Nav.Uri);
|
||||
@@ -220,21 +229,61 @@
|
||||
|
||||
private async Task RegisterAppAsync()
|
||||
{
|
||||
if (!CanRegister) return;
|
||||
if (!CanRegister || _registering) return;
|
||||
|
||||
_registering = true;
|
||||
_formError = string.Empty;
|
||||
_regStatus = "Requesting a sign-in code…";
|
||||
_deviceCode = null;
|
||||
_regCts = new CancellationTokenSource(TimeSpan.FromMinutes(15));
|
||||
StateHasChanged();
|
||||
|
||||
var returnUrl = Nav.Uri.Contains('?')
|
||||
? Nav.Uri.Substring(0, Nav.Uri.IndexOf('?'))
|
||||
: Nav.Uri;
|
||||
try
|
||||
{
|
||||
// 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" +
|
||||
$"?tenantId={Uri.EscapeDataString(_form.TenantId)}" +
|
||||
$"&tenantName={Uri.EscapeDataString(_form.Name)}" +
|
||||
$"&tenantUrl={Uri.EscapeDataString(_form.TenantUrl)}" +
|
||||
$"&returnUrl={Uri.EscapeDataString(returnUrl)}";
|
||||
var adminToken = await DeviceFlow.PollForAccessTokenAsync(_form.TenantId.Trim(), _deviceCode, _regCts.Token);
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user