Mastering Authorization in Umbraco 14/15: Real-World Management API Challenges and Solutions

Afreed - Nov 4 - - Dev Community

When I first started working with Umbraco's Management API, I was both excited and curious. This API promises headless management capabilities that can truly elevate automation and the integration of custom apps with Umbraco's backend. But as with many new tools, the real challenge emerged when I tried to implement it beyond the well-documented test environments.

In this article, I'll share the lessons I learned while trying to navigate the tricky landscape of Management API authorization in both local and production setups. Whether you're an experienced Umbraco developer or just getting started, I hope this helps you understand not only how to get things working but also why the current state of documentation can make it such a difficult journey.

Understanding the Management API Authorization

The Management API is a significant evolution for Umbraco, replacing the previous backoffice controllers with a more robust, RESTful approach. While its core functions are well-documented, the process of getting authorization tokens and managing secure access is not without its quirks, especially if you're looking beyond Swagger or Postman(https://docs.umbraco.com/umbraco-cms/reference/management-api/postman-setup-swagger).
Source : (https://docs.umbraco.com/umbraco-cms/reference/management-api)

The Swagger documentation for the Management API is primarily enabled in non-production environments. This means you can use it effectively for testing purposes, leveraging OAuth2 with PKCE in Postman or Swagger itself. However, when you move to a production environment, where Swagger is disabled, things become considerably more challenging. Only the 'umbraco-back-office' client is allowed in production, requiring careful handling to avoid conflicts and missing documentation coverage.

In production, Swagger isn't available, and the documentation leaves a lot of gaps for developers trying to set up a connection with OpenID Connect. Only the "umbraco-back-office" client is allowed to connect, while in non-production, you can use clients like "umbraco-swagger" or "umbraco-postman". This approach is understandable from a security perspective, but it introduces hurdles when setting up custom client integrations or ensuring a smooth workflow for deployments.

Missing Pieces: Local and Production Authorization

The current documentation provided by Umbraco is helpful for the initial setup in non-production environments, with clear instructions on using Postman to connect a backoffice user through OAuth2. However, if you're trying to replicate this authorization process in a local development environment or—more critically—in a production environment, you quickly find yourself without a map. Additionally, the documentation is not complete at the moment and may be updated by Umbraco HQ later, potentially when they have more concrete plans for the Management API, as they have recently introduced API users in Umbraco 15.

I recently set up authorization for the Management API on both local and production environments, and it became apparent that a lot of the steps were undocumented or required piecing together information from different parts of the Umbraco community. The official documentation focuses heavily on Swagger and Postman, which is ideal for testing but not quite enough when dealing with actual client applications or custom workflows.

For instance, in local environments, getting OpenID Connect to work smoothly often required manually adjusting configurations to align with non-production rules. Swagger and Postman setups would default to using "umbraco-swagger" or "umbraco-postman" as the client_id, which isn't valid in local production contexts. Moreover, in production, ensuring secure access meant diving into OAuth2 flows without a client secret—something not explicitly covered for most local and production scenarios in the documentation.

Authorization Pain Points and Workarounds

One of the key issues I encountered was trying to use the "umbraco-back-office" client as the client ID. You can specify the callback URL in the appsettings under Umbraco:CMS:Security:AuthorizeCallbackPathName, but there's a significant problem: the Umbraco backoffice uses the same client, which results in a broken callback and disrupts the backoffice login flow. Additionally, this issue is not documented, which makes troubleshooting even more challenging.
After investigating this matter, the best approach for now is to extend the OpenIdDictApplicationManagerBase and create a new client. By doing so, you can create a dedicated client for your integration without interfering with the default backoffice client, thus avoiding conflicts.

appsetting.json

{
  "BaseUrl": "https://localhost:44329",
  "ClientId": "newclientId", // generated through /create-client
  "AuthorizationEndpoint": "/umbraco/management/api/v1/security/back-office/authorize",
  "TokenEndpoint": "/umbraco/management/api/v1/security/back-office/token",
  "RedirectUri": "https://localhost:44329/callback"
}

Enter fullscreen mode Exit fullscreen mode

Here is a simplified version of my setup, using Minimal API to create a custom application manager:

using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
using OpenIddict.Core;
using UmbDemo14.Web;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Api.Management.Security;
using Umbraco.Cms.Infrastructure.Security;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.CreateUmbracoBuilder()
    .AddBackOffice()
    .AddWebsite()
    .AddDeliveryApi()
    .AddComposers()
    .Build();

builder.Services.AddScoped<CustomApplicationManager>(provider =>
{
    var applicationManager = provider.GetRequiredService<IOpenIddictApplicationManager>();
    return new CustomApplicationManager(applicationManager);
});
builder.Services.AddHttpClient();
builder.Services.AddSession();

builder.Services.AddTransient<Auth>();

builder.Services.AddCors(options =>
{
    options.AddPolicy("AllowAllOrigins",
        policy =>
        {
            policy.AllowAnyOrigin()   // Allows requests from any origin
                  .AllowAnyHeader()   // Allows any header
                  .AllowAnyMethod();  // Allows any HTTP method (GET, POST, etc.)
        });
});

WebApplication app = builder.Build();

await app.BootUmbracoAsync();

app.UseSession();
app.UseCors("AllowAllOrigins");

app.UseUmbraco()
    .WithMiddleware(u =>
    {
        u.UseBackOffice();
        u.UseWebsite();
    })
    .WithEndpoints(u =>
    {
        u.UseBackOfficeEndpoints();
        u.UseWebsiteEndpoints();
    });

app.MapPost("/create-client", async (ClientModel model, CustomApplicationManager applicationManager) =>
{
    try
    {
        if (string.IsNullOrEmpty(model.ClientId))
            return Results.BadRequest("Client ID is required.");

        if (!Uri.TryCreate(model.RedirectUri, UriKind.Absolute, out var redirectUri))
            return Results.BadRequest("Invalid redirect URI.");

        await applicationManager.EnsureCustomApplicationAsync(model.ClientId, redirectUri);
        return Results.Ok("Client created/updated successfully.");
    }
    catch (Exception ex)
    {
        return Results.Problem(ex.Message);
    }
}).WithName("CreateClient");

app.MapGet("/login", (Auth auth, IConfiguration config, IBackOfficeApplicationManager backOfficeApplicationManager) =>
{
    var baseUrl = config["Umbraco:BaseUrl"];
    var authorizationUrl = auth.GetAuthorizationUrl();
    return Results.Redirect(baseUrl + authorizationUrl);
});

app.MapGet("/callback", async (Auth auth, HttpContext httpContext, IConfiguration configuration) =>
{
    var code = httpContext.Request.Query["code"];
    var state = httpContext.Request.Query["state"];
    if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
    {
        return Results.BadRequest("Invalid callback parameters");
    }
    try
    {
        var tokenResponse = await auth.HandleCallback(code, state);
        // Store the token securely
        httpContext.Response.Cookies.Append("UmbracoToken", tokenResponse, new CookieOptions
        {
            HttpOnly = true,
            Secure = true,
            SameSite = SameSiteMode.Strict
        });
        return Results.Redirect("/dashboard");
    }
    catch (Exception ex)
    {
        return Results.BadRequest($"Authentication failed: {ex.Message}");
    }
});

await app.RunAsync();

public class ClientModel
{
    public string ClientId { get; set; }
    public string RedirectUri { get; set; }
    //public string ClientSecret { get; set; } // only use if it's a confidential app
}
Enter fullscreen mode Exit fullscreen mode

And here is the implementation for the CustomApplicationManager class:
Source : (https://github.com/umbraco/Umbraco-CMS/blob/contrib/src/Umbraco.Cms.Api.Management/Security/BackOfficeApplicationManager.cs)

using OpenIddict.Abstractions;
using Umbraco.Cms.Infrastructure.Security;

namespace UmbDemo14.Web
{
    public class CustomApplicationManager : OpenIdDictApplicationManagerBase
    {
        public CustomApplicationManager(IOpenIddictApplicationManager applicationManager)
            : base(applicationManager)
        {
        }

        public async Task EnsureCustomApplicationAsync(string clientId, Uri redirectUri, CancellationToken cancellationToken = default)
        {
            if (redirectUri.IsAbsoluteUri == false)
            {
                throw new ArgumentException("The provided URL must be an absolute URL.", nameof(redirectUri));
            }

            var clientDescriptor = new OpenIddictApplicationDescriptor
            {
                DisplayName = "Custom Application",
                ClientId = clientId,
                RedirectUris = { redirectUri },
                ClientType = OpenIddictConstants.ClientTypes.Public, // change to confidential for client secret
                Permissions = {
                    OpenIddictConstants.Permissions.Endpoints.Authorization,
                    OpenIddictConstants.Permissions.Endpoints.Token,
                    OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
                    OpenIddictConstants.Permissions.ResponseTypes.Code
                }
            };

            await CreateOrUpdate(clientDescriptor, cancellationToken);
        }
        public async Task DeleteCustomClientApplicationAsync(string clientId, CancellationToken cancellationToken)
        {
            await Delete(clientId, cancellationToken);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Moving Forward: How We Can Improve

Umbraco's Management API is a powerful tool, but like any tool, its full potential can only be realized if users can understand how to wield it effectively. I'd like to see Umbraco expand its documentation to include more practical, step-by-step guides for setting up the Management API in both local and production environments. This should include:

  • Detailed Walkthroughs for Production Authorization: Covering OAuth2 setup, best practices for security, and using "umbraco-back-office" in a production scenario without Swagger.

  • Local Environment Tips: Providing more explicit instructions on how to translate Swagger/Postman authorizations into a local setup. This would help reduce confusion for developers working in a hybrid workflow.

By addressing these areas, Umbraco can significantly enhance the developer experience, ultimately making it easier for the community to adopt, extend, and innovate with the Management API.

Conclusion

The Management API's current state is a solid foundation, but authorization remains an area where there's room for improvement. Developers working with local and production environments need more than just Swagger or Postman examples; they need a complete guide to setting up secure and flexible integrations. I'm hopeful that with continued community feedback and contributions, Umbraco's documentation will grow to cover these gaps, making the Management API more accessible to everyone.

.