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:
2026-06-02 11:47:23 +02:00
parent 2dd33cc6c2
commit bcced08caf
4 changed files with 240 additions and 184 deletions
+90 -41
View File
@@ -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()
+4 -109
View File
@@ -58,52 +58,9 @@ public static class OAuthEndpoints
return Results.Redirect(authUrl);
});
// ── Register: initiate admin auth to create app registration in client tenant
app.MapGet("/connect/register-initiate", (
HttpContext ctx,
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 ────────────────────
// ── Connect callback: exchange the auth code for the client-tenant session ─
// (App registration uses a separate secretless device-code flow — see
// EntraDeviceCodeFlow — and does not pass through this endpoint.)
app.MapGet("/connect/callback", async (
string? code,
string? state,
@@ -111,8 +68,6 @@ public static class OAuthEndpoints
string? error_description,
IOAuthFlowCache flowCache,
IOptions<ClientConnectOptions> opts,
IConfiguration config,
IAppRegistrationService appRegService,
IHttpClientFactory httpClientFactory) =>
{
if (!string.IsNullOrEmpty(error))
@@ -131,66 +86,7 @@ public static class OAuthEndpoints
var o = opts.Value;
var http = httpClientFactory.CreateClient("oauth");
if (flowState.IsRegistration)
{
// ── 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) ──
// Connect flow: public client exchange (profile ClientId, PKCE, no secret).
var body = new Dictionary<string, string>
{
["grant_type"] = "authorization_code",
@@ -230,7 +126,6 @@ public static class OAuthEndpoints
var returnTo = QueryHelpers.AddQueryString(flowState.ReturnUrl, "token_key", tokenKey);
return Results.Redirect(returnTo);
}
});
return app;
+1
View File
@@ -122,6 +122,7 @@ builder.Services.AddSingleton(new AuditRepository(Path.Combine(dataFolder, "audi
// ── Auth infrastructure ───────────────────────────────────────────────────────
builder.Services.AddSingleton<IUserService, UserService>();
builder.Services.AddSingleton<IOAuthFlowCache, OAuthFlowCache>();
builder.Services.AddSingleton<IEntraDeviceCodeFlow, EntraDeviceCodeFlow>();
builder.Services.AddHttpClient<ITokenRefreshService, TokenRefreshService>();
builder.Services.AddHttpClient<IAppRegistrationService, AppRegistrationService>();
builder.Services.AddScoped<GraphClientFactory>();
+111
View File
@@ -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}");
}
}
}
}