diff --git a/Components/Pages/Profiles.razor b/Components/Pages/Profiles.razor index e4f6074..2b5070f 100644 --- a/Components/Pages/Profiles.razor +++ b/Components/Pages/Profiles.razor @@ -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 ConnectOpts @rendermode InteractiveServer @using Microsoft.AspNetCore.WebUtilities @using SharepointToolbox.Web.Core.Models @@ -111,14 +113,37 @@ style="flex:1" /> - 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. + + @if (_deviceCode is not null) + { +
+
Sign in to the client tenant to authorize app creation:
+
    +
  1. Open @_deviceCode.VerificationUri
  2. +
  3. Enter code: + @_deviceCode.UserCode +
  4. +
  5. Approve the requested permissions with an admin account.
  6. +
+
+ @_regStatus + +
+
+ } + else if (!string.IsNullOrEmpty(_regStatus)) + { +
@_regStatus
+ }
@@ -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() diff --git a/Infrastructure/OAuth/OAuthEndpoints.cs b/Infrastructure/OAuth/OAuthEndpoints.cs index f3206a4..21f690b 100644 --- a/Infrastructure/OAuth/OAuthEndpoints.cs +++ b/Infrastructure/OAuth/OAuthEndpoints.cs @@ -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 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 opts, - IConfiguration config, - IAppRegistrationService appRegService, IHttpClientFactory httpClientFactory) => { if (!string.IsNullOrEmpty(error)) @@ -131,106 +86,46 @@ public static class OAuthEndpoints var o = opts.Value; var http = httpClientFactory.CreateClient("oauth"); - if (flowState.IsRegistration) + // Connect flow: public client exchange (profile ClientId, PKCE, no secret). + var body = new Dictionary { - // ── Registration flow: public client exchange (PKCE only, no secret) ── - var oidcClientId = config["Oidc:ClientId"]!; + ["grant_type"] = "authorization_code", + ["client_id"] = flowState.ClientId, + ["code"] = code, + ["redirect_uri"] = o.RedirectUri, + ["code_verifier"] = flowState.CodeVerifier, + ["scope"] = "openid offline_access", + }; - var body = new Dictionary - { - ["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(); - 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 + if (!resp.IsSuccessStatusCode) { - // ── Connect flow: public client exchange (profile ClientId, no secret) ── - var body = new Dictionary - { - ["grant_type"] = "authorization_code", - ["client_id"] = flowState.ClientId, - ["code"] = code, - ["redirect_uri"] = o.RedirectUri, - ["code_verifier"] = flowState.CodeVerifier, - ["scope"] = "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($"Token exchange failed: {json}"); - return Results.Redirect($"/?connect_error={msg}"); - } - - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - var upn = ExtractUpnFromIdToken(root); - var refreshToken = root.GetProperty("refresh_token").GetString()!; - - var tokens = new SessionTokens - { - RefreshToken = refreshToken, - TenantId = flowState.TenantId, - ClientId = flowState.ClientId, - SpHost = flowState.SpHost, - UserPrincipalName = upn, - }; - - var tokenKey = Guid.NewGuid().ToString("N"); - flowCache.StoreTokens(tokenKey, tokens); - - var returnTo = QueryHelpers.AddQueryString(flowState.ReturnUrl, "token_key", tokenKey); - return Results.Redirect(returnTo); + var msg = Uri.EscapeDataString($"Token exchange failed: {json}"); + return Results.Redirect($"/?connect_error={msg}"); } + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var upn = ExtractUpnFromIdToken(root); + var refreshToken = root.GetProperty("refresh_token").GetString()!; + + var tokens = new SessionTokens + { + RefreshToken = refreshToken, + TenantId = flowState.TenantId, + ClientId = flowState.ClientId, + SpHost = flowState.SpHost, + UserPrincipalName = upn, + }; + + var tokenKey = Guid.NewGuid().ToString("N"); + flowCache.StoreTokens(tokenKey, tokens); + + var returnTo = QueryHelpers.AddQueryString(flowState.ReturnUrl, "token_key", tokenKey); + return Results.Redirect(returnTo); }); return app; diff --git a/Program.cs b/Program.cs index a2cfe51..b491d7c 100644 --- a/Program.cs +++ b/Program.cs @@ -122,6 +122,7 @@ builder.Services.AddSingleton(new AuditRepository(Path.Combine(dataFolder, "audi // ── Auth infrastructure ─────────────────────────────────────────────────────── builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); builder.Services.AddScoped(); diff --git a/Services/OAuth/DeviceCodeFlow.cs b/Services/OAuth/DeviceCodeFlow.cs new file mode 100644 index 0000000..3bbd1f1 --- /dev/null +++ b/Services/OAuth/DeviceCodeFlow.cs @@ -0,0 +1,111 @@ +using System.Text.Json; + +namespace SharepointToolbox.Web.Services.OAuth; + +/// Result of initiating a device code flow — shown to the admin. +public record DeviceCodeStart( + string DeviceCode, + string UserCode, + string VerificationUri, + int Interval, + int ExpiresIn, + string Message); + +public interface IEntraDeviceCodeFlow +{ + /// Begins a device code flow against the given tenant. Returns the user code + verification URI to display. + Task BeginAsync(string tenantId, string scope, CancellationToken ct); + + /// Polls the token endpoint until the admin completes sign-in. Returns the Graph access token. + Task PollForAccessTokenAsync(string tenantId, DeviceCodeStart start, CancellationToken ct); +} + +/// +/// 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. +/// +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 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 + { + ["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 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 + { + ["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}"); + } + } + } +}