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
+38 -143
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,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<string, string>
{
// ── 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<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();
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<string, string>
{
["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;