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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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