Question about converting UmbracoAuthorizedApiController to ManagementApiControllerBase

Hello, friends,

I have a utility package (v13) which uses UmbracoAuthorizedApiController such that a logged-in back-office user could open a browser at a URL like https://mysite.com/umbraco/backoffice/MyPackage/GetSomething?param=true and the endpoint would return an HTML page constructed by some Razor files and info from the Umbraco install.

As a first step in updating this package to support v17, I wanted to test out the most basic webapi setup in a new (empty) Umbraco 17 site.

Following the documentation and some other packages on GitHub for examples, I have this simple demo:

Base Controller:

    using Asp.Versioning;
    using Microsoft.AspNetCore.Authorization;
    using Microsoft.AspNetCore.Mvc;
    using Umbraco.Cms.Api.Common.Attributes;
    using Umbraco.Cms.Api.Common.Filters;
    using Umbraco.Cms.Api.Management.Controllers;
    using Umbraco.Cms.Web.Common.Authorization;
    using UmbConstants = Umbraco.Cms.Core.Constants;
    
    
    [ApiController]
    [ApiVersion(SiteSpecificApiConfig.ApiVersion)]
    [MapToApi(SiteSpecificApiConfig.ProjectAlias)]
    [ApiExplorerSettings(GroupName = SiteSpecificApiConfig.ProjectDisplayName)]
    [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)]
    [JsonOptionsName(UmbConstants.JsonOptionsNames.BackOffice)]
    public abstract class SiteSpecificApiControllerBase : ManagementApiControllerBase
    {
    }

Demo Controller:

	using System;
	using System.Net.Http;
	using System.Text;
	using Microsoft.AspNetCore.Http;
	using Microsoft.AspNetCore.Mvc;
	using Microsoft.Extensions.Logging;
	using Umbraco.Cms.Core.Models.Membership;
	using Umbraco.Cms.Core.Security;
	
	
	[SiteSpecificApiBackOfficeRoute("AuthorizedApi")]
	public class AuthorizedApiController : SiteSpecificApiControllerBase
	{
		#region CTOR/DI
	
		private readonly ILogger _logger;
		private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
	
		public AuthorizedApiController(
			ILogger<AuthorizedApiController> logger
			, IBackOfficeSecurityAccessor backOfficeSecurityAccessor
		)
		{
			_logger = logger;
			_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
		}
	
		#endregion
	
		#region Tests & Examples
	
		[HttpGet("Test")]
		[ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
		public bool Test()
		{
			return true;
		}
	
		[HttpGet("SayHello")]
		[ProducesResponseType(typeof(string), StatusCodes.Status200OK)]
		public IActionResult SayHello()
		{
			IUser currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser
			                    ?? throw new InvalidOperationException("No backoffice user found");
			return Ok($"Hello, {currentUser.Name}");
		}
	
		[HttpGet("ExampleReturnHtml")]
		[ProducesResponseType(typeof(ContentResult), StatusCodes.Status200OK)]
		public IActionResult ExampleReturnHtml()
		{
			var returnSB = new StringBuilder();
	
			returnSB.AppendLine("<h1>Hello! This is HTML</h1>");
			returnSB.AppendLine($"<p>This is rendering on {DateTime.Today.ToShortDateString()}</p>");
	
			return new ContentResult()
			{
				Content = new StringContent(
					returnSB.ToString(),
					Encoding.UTF8,
					"text/html"
				).ToString(),
				StatusCode = 200,
				ContentType = "text/html"
			};
		}
	
		[HttpGet("ExampleReturnJson")]
		[ProducesResponseType(typeof(JsonResult), StatusCodes.Status200OK)]
		public IActionResult ExampleReturnJson()
		{
			var testData1 = new TimeSpan(1, 1, 1, 1);
			//  var testData2= new StatusMessage(true, "This is a test object so you can see JSON!");
	
			return new JsonResult(testData1);
		}
	
		#endregion
	}

When I go to http://mysite.local/umbraco/swagger/ I can select this controller in the drop-down, and it shows the various endpoints. If I use the “Authorize” button to provide the back-office token, the “Try it out”/“Execute” buttons work.

However, when I try to access these endpoints directly via a browser to the URL (ex: https://mysite.local/MySite/api/v1/AuthorizedApi/SayHello ), I get ERR_HTTP_RESPONSE_CODE_FAILURE 401 (Unauthorized), which makes sense I suppose, because the Swagger UI is not magically providing the authorization.

So, my question is - what do I need to add to my Controller code in order to make this work in a browser directly?

2 Likes

I believe the OAuth2 backoffice authorized endpoints require an Authorization header to work.

Ex.

curl -X 'GET' \
  'https://localhost:44377/my-backoffice-api-v1/reload-cache' \
  -H 'accept: application/json' \
  -H 'Authorization: Bearer B0MTSWzSeMuF-N_jQn1fDnmXZRGfzHPGDxKgXmWFNEw'

In order to hit your url and pass the authorization header from a backoffice area of your site, you can use the UMB_AUTH_CONTEXT from @umbraco-cms/backoffice/auth to append the authorization.

Here is a simple example, written in typescript and compiled as a standard lit module:

my-backoffice-api-dashboard.ts:

import { css, html, customElement, state } from "@umbraco-cms/backoffice/external/lit";
import { UmbLitElement } from "@umbraco-cms/backoffice/lit-element";
import { UMB_AUTH_CONTEXT } from '@umbraco-cms/backoffice/auth';
import { UMB_NOTIFICATION_CONTEXT, UmbNotificationContext } from "@umbraco-cms/backoffice/notification";

@customElement('my-backoffice-dashboard')
export default class MyBackofficeDashboardElement extends UmbLitElement {

    _notificationContext?: UmbNotificationContext;

    @state()
    public loading: boolean = false;


    constructor() {
        super();

        this.consumeContext(UMB_NOTIFICATION_CONTEXT, (instance) => {
            this._notificationContext = instance;
        });
    }

    private fireReload() {
        var self = this;
        this.consumeContext(UMB_AUTH_CONTEXT, async (auth) => {
            if (!auth) return;

            this.loading = true;
            
            const umbOpenApi = auth.getOpenApiConfiguration();
            await umbOpenApi.token().then(function (TOKEN) {

                //execute oauth 2 secured endpoint
                fetch('/my-backoffice-api-v1/reload-cache/', {
                    method: 'GET',
                    headers: {
                        'Authorization': 'Bearer ' + TOKEN,
                        'Accept': 'application/json',
                        'Content-Type': 'application/json'
                    },
                }).then(function (resp) {
                    console.log("resp", resp);
                    if (resp.status !== 200) {
                        //error
                        console.log("Error", resp);
                        setTimeout(function () {
                            self.loading = false;
                            self._notificationContext?.peek('danger', {
                                data: {
                                    headline: "Error",
                                    message: "Network error"
                                }
                            });
                        }, 0);
                    }
                    else {
                        resp.json().then(function (result) {
                            if (result.success == true) {
                                self._notificationContext?.peek('positive', {
                                    data: {
                                        headline: "Success",
                                        message: result.data
                                    }
                                });
                            }
                            else {
                                if (result.messages != null && result.messages.length > 0) {
                                    
                                    for (let i = 0; i < result.messages.length; i++) {
                                        self._notificationContext?.peek('danger', {
                                            data: {
                                                headline: "Error",
                                                message: "Error: " + result.messages[i]
                                            }
                                        });
                                    }
                                    
                                }
                                
                            }
                            self.loading = false;
                            setTimeout(function () { self.loading = false; }, 0);
                        });
                    }
                });
            });
        });
    }
    
    render() {
        return html`
        <div class="tab-content">
            <h2 class="headline">My Backoffice Dashboard</h2>
            

            <uui-button look="primary" color="positive" type="button" state=${this.loading == true ? "waiting" : null} @click="${{ handleEvent: () => this.fireReload(), once: false }}">Reload</uui-button>
        </div>
        `;
    }
    static styles = [
        css`
      :host {
        display: block;
        padding: 24px;
      }
      .tab-content{
          padding: 24px;
          background-color: white;
          border: 1px solid var(--uui-color-border);
          border-radius: calc(var(--uui-border-radius)* 2);
      }
      .tab-content code{
        padding: 2px 4px;
        color: #d14;
        white-space: nowrap;
        background-color: #f7f7f9;
        border: 1px solid #e1e1e8;
      }
      h2{

      }
      .errors{
          color: darkred;
          margin-bottom: 15px;
          background-color: #ffe8e8;
          padding: 5px 15px;
      }
      .success{
          color: #0b8152;
          margin-bottom: 15px;
          background-color: #e7ffe7;
          padding: 5px 15px;
      }
    `,
    ];
}

declare global {
    interface HTMLElementTagNameMap {
        'my-backoffice-dashboard': MyBackofficeDashboardElement;
    }
}

In this example, the App_Plugins\MyBackofficeApiDashboard\umbraco-package.json might look like this (it’s a dashboard)

{
  "$schema": "../../umbraco-package-schema.json",
  "allowPackageTelemetry": true,
  "extensions": [
    {
      "type": "dashboard",
      "alias": "MyBackofficeDashboard",
      "name": "My Backoffice Dashboard",
      "element": "/App_Plugins/MyBackofficeApiDashboard/js/my-backoffice-api-dashboard.js",
      "elementName": "my-backoffice-dashboard",
      "weight": 20,
      "meta": {
        "label": "My Backoffice Dashboard",
        "pathname": "my-backoffice-dashboard"
      },
      "conditions": [
        {
          "alias": "Umb.Condition.SectionAlias",
          "match": "Umb.Section.Settings"
        }
      ]
    }
  ],
  "name": "My Backoffice Dashboard",
  "version": "1.0.0"
}

and the vite.config.ts might look like this:

import { defineConfig } from "vite";

export default defineConfig({
    build: {
        lib: {
            entry: "src/my-backoffice-api-dashboard.ts", // your web component source file
            formats: ["es"],
        },
        outDir: "../../Website/App_Plugins/MyBackofficeApiDashboard/js", //"dist", // your web component will be saved in this location
        sourcemap: true,
        rollupOptions: {
            external: [/^@umbraco/], //
        },
    },
});

If you generate a project using the Umbraco Extension Template | CMS | Umbraco Documentation

Then much of this boiler plate is done for you..

make use of npm run generate-client and then you get an import to use..
(it generates this from the api controllers you add to the project.. with the examples there are already a few there)

import { InviteToConfigurePlotService } from "../api/index.js";
...
const {data, error } = await InviteToConfigurePlotService.whoAmI({
    body: bodyData
});

And the auth headers are all ready all in place for the backoffice comms.. :slight_smile: in the entrypoint.ts

import type {
  UmbEntryPointOnInit,
  UmbEntryPointOnUnload,
} from "@umbraco-cms/backoffice/extension-api";
import { UMB_AUTH_CONTEXT } from "@umbraco-cms/backoffice/auth";
import { client } from "../api/client.gen.js";

// load up the manifests here
export const onInit: UmbEntryPointOnInit = (_host, _extensionRegistry) => {
  console.log("Hello from my extension 🎉");
  // Will use only to add in Open API config with generated TS OpenAPI HTTPS Client
  // Do the OAuth token handshake stuff
  _host.consumeContext(UMB_AUTH_CONTEXT, async (authContext) => {
    // Get the token info from Umbraco
    const config = authContext?.getOpenApiConfiguration();

    client.setConfig({
      auth: config?.token ?? undefined,
      baseUrl: config?.base ?? "",
      credentials: config?.credentials ?? "same-origin",
    });
  });
};

export const onUnload: UmbEntryPointOnUnload = (_host, _extensionRegistry) => {
  console.log("Goodbye from my extension 👋");
};

ps.. My project name was InviteToConfigurePlot so yours would be different. :slight_smile:
.. though they don’t use the ManagementApiControllerBase, they are backoffice protected, I think this is only so that you don’t have to have /management/ in the backoffice route)

[ApiController]
[BackOfficeRoute("invitetoconfigureplot/api/v{version:apiVersion}")]
[Authorize(Policy = AuthorizationPolicies.SectionAccessContent)]
[MapToApi(Constants.ApiName)]
public class InviteToConfigurePlotApiControllerBase : ControllerBase
{
}

but nothing to stop you changing this to suit your requirements.

One other gotcha.. the default dependencies from the extension template are set as * I’d recommend setting these to concrete versions… otherwise you’ll find any project consuming this project will update to the latest umbraco version, which you may not want..

<ItemGroup>
  <PackageReference Include="Umbraco.Cms.Web.Website" Version="*" />
  <PackageReference Include="Umbraco.Cms.Web.Common" Version="*" />
  <PackageReference Include="Umbraco.Cms.Api.Common" Version="*" />
  <PackageReference Include="Umbraco.Cms.Api.Management" Version="*" />
</ItemGroup>

Umbraco self upgrading from 17.1.0 to 17.2.0 on Azure Web App - Umbraco community forum

If it’s a nuget package you are creating, the opinionated starter kit is also great!
GitHub - LottePitcher/opinionated-package-starter: Get a head start when creating Umbraco Packages ¡ GitHub (it wraps the extension project with lots of package development sugar.. test site etc)

Thanks so much, @mistyn8 and @asawyer. I appreciate the “quick start” for back-office components.

It sounds like there is no way to utilize the URLs directly, then, without having it link from a dashboard?

The old UmbracoAuthorizedApiController would just work if you were logged-in to the back-office in the same browser, and if you weren’t logged-in would redirect you to the Umbraco login screen. It was a nice functionality for quickly writing up one-use on-site utils, without having to put together all the UI for back-office use.

Also, I was hopeful that my current utility project could still have the same on-the-fly URL functionality, since then I could “bookmark” or share URLs built-out with parameters. It seems like that wouldn’t be a possibility any longer. :slightly_frowning_face: Unless I am missing a creative possibility?

External Access | CMS | Umbraco Documentation

and

Setup OAuth using Postman | CMS | Umbraco Documentation

Might give you some insight into how to get the bearer tokens outside of a backoffice entity.

Or indeed how to use these tools for your testing?
(Echo Api for VS Code, is a free alternative to Postman…)

You can do this, but you’d have to use your own authorization logic, and not specify the backoffice authorize attribute.

For example, you could check if a currently logged in user exists for the request context within your controller:


[ApiController]
[ApiVersion("1.0")]
[MapToApi("my-backoffice-api")]
[JsonOptionsName(Umbraco.Cms.Core.Constants.JsonOptionsNames.BackOffice)]
[Route("umbraco/my-backoffice-api/v{version:apiVersion}")]
public class MyBackofficeApiController : ControllerBase
{
    private readonly IUmbracoContextAccessor _umbracoContextAccessor;
    private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;

    public MyBackofficeApiController(IUmbracoContextAccessor umbracoContextAccessor, IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
    {
        _umbracoContextAccessor = umbracoContextAccessor;
        _backOfficeSecurityAccessor = backOfficeSecurityAccessor;
    }

    [HttpGet("get-thing")]
    [MapToApiVersion("1.0")]
    [ProducesResponseType(typeof(MyObject>), StatusCodes.Status200OK)]
    public MyObject GetThing(Guid guidKey)
    {
        var result = new MyObject();

        IUser currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser
                        ?? throw new InvalidOperationException("No backoffice user found");
						
		/// CODE HERE TO POPULATE result			
						
						
		return result;
						
	}
}

If going this route, the authorization logic really should be turned into a new attribute, so it can be centralized and reused. I also don’t know if this violates best practices, but it should work.

1 Like

@asawyer I SOOO wanted this to Just Work™, but alas, BackOfficeSecurityAccessor?.BackOfficeSecurity?.CurrentUser is always null - even while logged-in to the back-office in another browser tab.

Also, a simple check for Cookies["__Host-umbAccessToken"] also doesn’t work (because… security :wink: )

It looks like I’ll need to dig into @mistyn8 's “External Access” docs…

Thanks for these links, @mistyn8.

I was looking at implementing the example in the “External Access” doc, but I see that this example requires an API User added to the system… which would be fine, except that if the code is set up on the site, then it would of course have the correct API key, and I am still trying to figure out how to check that the person trying to view the page is logged-in… :zany_face:

I saw this other (now closed) thread by @skartknet where he was able to hook into the back-office token using localStorage somehow, but no code snippets were provided, so I’m not sure how that would work…

@jacob says

But I’m still not sure how to get the “Backoffice access_token” in my controller :face_with_crossed_out_eyes:

Hi Heather,

I’ve been spending a lot of time doing V13 -V17 stuff for a talk I am doing. Getting the Bearer token from client side is pretty simple and that’s what most examples I’ve seen use.

If you want to be able to call a URL manually and it work as long as a user is logged in then I think a normal Controller with a custom attribute which checks the ‘BackOfficeSecurityAccessor’ should suffice and I’ve got a working example of this in V13, but this should still work in 17.

When I am at my desk later today I’ll have a play and check it for you and then share my findings as this is something I also do quite a bit.

J

1 Like

Hi @HFloyd

So I got this working, it’s very rough as but you should be able to rework to do only what you require more elegantly.

Create a file BackofficeUserAccessor.cs

public class BackofficeUserAccessor(IOptionsSnapshot cookieOptionsSnapshot, IHttpContextAccessor httpContextAccessor) : IBackofficeUserAccessor
{
public ClaimsIdentity BackofficeUser
{
get
{
var httpContext = httpContextAccessor.HttpContext;

        if (httpContext == null)
            return new ClaimsIdentity();

        var cookieOptions = cookieOptionsSnapshot.Get(Constants.Security.BackOfficeAuthenticationType);
        var backOfficeCookie = httpContext.Request.Cookies[cookieOptions.Cookie.Name!];

        if (string.IsNullOrEmpty(backOfficeCookie))
            return new ClaimsIdentity();

        var unprotected = cookieOptions.TicketDataFormat.Unprotect(backOfficeCookie!);

        if (unprotected == null)
            return new ClaimsIdentity();

        var backOfficeIdentity = unprotected!.Principal.GetUmbracoIdentity();

        return backOfficeIdentity ?? new ClaimsIdentity();
    }
}

}

In a composer:


builder.services.AddTransient<IBackofficeUserAccessor, BackofficeUserAccessor >();

Create a controller:

[Route(“/umbraco/secured-controller”)]
public class AuthenticatedController : Controller
{
private readonly IBackofficeUserAccessor _backofficeUserAccessor;

public AuthenticatedController(IBackofficeUserAccessor backofficeUserAccessor)
{
    _backofficeUserAccessor = backofficeUserAccessor;
}

[HttpGet]
[Route("do-something")]
public async Task<IActionResult> DoSomething()
{
    if (!_backofficeUserAccessor.BackofficeUser.IsAuthenticated)
    {
        return Unauthorized();
    }

    // Do something 

    return Ok();
}

}

Nice! My example pulled from an Umbraco 13 project, so that probably explains why it doesn’t work in Umbraco 17 (too many projects, I had forgotten).

I did run into the issue with the object being null in a more recent project, my solution was almost exactly the same as Jamie’s:

//dependency injection of IOptionsSnapshot<CookieAuthenticationOptions> cookieOptionsSnapshot

//....


CookieAuthenticationOptions cookieOptions = cookieOptionsSnapshot.Get(Umbraco.Cms.Core.Constants.Security.BackOfficeAuthenticationType);
var backOfficeCookie = context.Request.Cookies[cookieOptions.Cookie.Name!];
var unprotected = cookieOptions.TicketDataFormat.Unprotect(backOfficeCookie!);
ClaimsIdentity backOfficeIdentity = new ClaimsIdentity();
if (unprotected != null)
{
    backOfficeIdentity = unprotected!.Principal.GetUmbracoIdentity() ?? new ClaimsIdentity();
}
loggedIn = backOfficeIdentity?.IsAuthenticated == true;

//...
1 Like

Thanks @JamieT & @asawyer ! I was able to get it working. Here are all the basic files, in case anyone else is looking for a full solution:

BackofficeUserAccessor.cs

using System.Security.Claims;

using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;

using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Extensions;
using UmbConstants = Umbraco.Cms.Core.Constants;

public interface IBackofficeUserAccessor
{
    ClaimsIdentity BackofficeUser { get; }
}

public class BackofficeUserAccessor(
    IOptionsSnapshot<CookieAuthenticationOptions> cookieOptionsSnapshot
    , IHttpContextAccessor httpContextAccessor
    ) : IBackofficeUserAccessor
{

    public ClaimsIdentity BackofficeUser
    {
        get
        {
            var httpContext = httpContextAccessor.HttpContext;

            if (httpContext == null)
                return new ClaimsIdentity();

            var cookieOptions = cookieOptionsSnapshot.Get(UmbConstants.Security.BackOfficeAuthenticationType);
            var backOfficeCookie = httpContext.Request.Cookies[cookieOptions.Cookie.Name!];

            if (string.IsNullOrEmpty(backOfficeCookie))
                return new ClaimsIdentity();

            var unprotected = cookieOptions.TicketDataFormat.Unprotect(backOfficeCookie!);

            if (unprotected == null)
                return new ClaimsIdentity();

            var backOfficeIdentity = unprotected!.Principal.GetUmbracoIdentity();

            return backOfficeIdentity ?? new ClaimsIdentity();
        }
    }

}

public class BackofficeUserAccessorComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        builder.Services.AddTransient<IBackofficeUserAccessor, BackofficeUserAccessor>();
    }
}

SiteSpecificApiControllerBase.cs

using System.Security.Claims;
using Newtonsoft.Json;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Common.Attributes;

[ApiController]
[ApiVersion(SiteSpecificApiConfig.ApiVersion)]
[MapToApi(SiteSpecificApiConfig.ProjectAlias)]
[ApiExplorerSettings(GroupName = SiteSpecificApiConfig.ProjectDisplayName)]
public abstract class SiteSpecificApiControllerBase(IBackofficeUserAccessor backofficeUserAccessor) : ControllerBase
{
    protected IBackofficeUserAccessor _backofficeUserAccessor { get; } = backofficeUserAccessor;

    internal bool IsBackOfficeAuthorized()
    {
        if (!_backofficeUserAccessor.BackofficeUser.IsAuthenticated)
        {
            return false;
        }

        return true;
    }

    internal string? CurrentBackofficeUserName()
    {
        if (IsBackOfficeAuthorized())
        {
            return _backofficeUserAccessor.BackofficeUser.Name;
        }
        else
        {
            return null;
        }
    }

    internal BackOfficeUserInfo CurrentBackofficeUser()
    {
        if (IsBackOfficeAuthorized())
        {
            return new BackOfficeUserInfo(_backofficeUserAccessor.BackofficeUser);
        }
        else
        {
            return null;
        }
    }
}

internal class BackOfficeUserInfo
{
    public string UserName { get; }
    public string UserGuid { get; }
    public string DisplayName { get; }
    public string CultureCode { get; }
    public string EmailAddress { get; }

    public IEnumerable<string> BackOfficeRoles { get; }
    public IEnumerable<string> AllowedApps { get; }

    public int StartMediaNodeId { get; }
    public int StartContentNodeId { get; }
    public int UserNodeId { get; }

    [JsonIgnore]
    public List<Claim> AllClaims { get; }

    internal BackOfficeUserInfo(ClaimsIdentity ClaimsId)
    {
        //Strings
        UserName = ClaimsId.Name ?? "";
        AllClaims = ClaimsId.Claims.ToList();
        DisplayName = ClaimsId.Claims.FirstOrDefault(x => x.Type == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname")?.Value ?? "";
        EmailAddress = ClaimsId.Claims.FirstOrDefault(x => x.Type == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress")?.Value ?? "";
        CultureCode = ClaimsId.Claims.FirstOrDefault(x => x.Type == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/locality")?.Value ?? "";
        UserGuid = ClaimsId.Claims.FirstOrDefault(x => x.Type == "sub")?.Value ?? "";

        //Ints
        var userIdStr = ClaimsId.Claims.FirstOrDefault(x => x.Type == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
        UserNodeId = String.IsNullOrEmpty(userIdStr) ? 0 : Convert.ToInt32(userIdStr);

        var contentNodeStr = ClaimsId.Claims.FirstOrDefault(x => x.Type == "http://umbraco.org/2015/02/identity/claims/backoffice/startcontentnode")?.Value;
        StartContentNodeId = String.IsNullOrEmpty(contentNodeStr) ? 0 : Convert.ToInt32(contentNodeStr);

        var mediaNodeStr = ClaimsId.Claims.FirstOrDefault(x => x.Type == "http://umbraco.org/2015/02/identity/claims/backoffice/startmedianode")?.Value;
        StartMediaNodeId = String.IsNullOrEmpty(mediaNodeStr) ? 0 : Convert.ToInt32(mediaNodeStr);

        //Other
        AllowedApps = ClaimsId.Claims.Where(x => x.Type == "http://umbraco.org/2015/02/identity/claims/backoffice/allowedapp").Select(c => c.Value);
        BackOfficeRoles = ClaimsId.Claims.Where(x => x.Type == "http://schemas.microsoft.com/ws/2008/06/identity/claims/role").Select(c => c.Value);
             
    }

}

AuthorizedApiController.cs

using System;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;

[SiteSpecificApiBackOfficeRoute("AuthorizedApi")]
public class AuthorizedApiController(
     ILogger<AuthorizedApiController> logger
    , IBackofficeUserAccessor backofficeUserAccessor
     // , IUmbracoContextAccessor umbracoContextAccessor
     ) : SiteSpecificApiControllerBase(backofficeUserAccessor)
{
    private readonly ILogger<AuthorizedApiController> _logger = logger;
    //private readonly IUmbracoContextAccessor _umbracoContextAccessor = umbracoContextAccessor;
    #region Tests & Examples

    [HttpGet("Test")]
    [ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
    public bool Test()
    {
        return true;
    }

    [HttpGet("SayHello")]
    [ProducesResponseType(typeof(string), StatusCodes.Status200OK)]
    public IActionResult SayHello()
    {
        if (!IsBackOfficeAuthorized())
        {
            return Unauthorized("You must be logged-in to the back-office to use this");
        }

        var userInfo = CurrentBackofficeUserName();

        return Ok($"Hello, {userInfo} ");

    }

    [HttpGet("ExampleReturnHtml")]
    [ProducesResponseType(typeof(ContentResult), StatusCodes.Status200OK)]
    public IActionResult ExampleReturnHtml()
    {
        var returnSB = new StringBuilder();

        if (!IsBackOfficeAuthorized())
        {
            returnSB.AppendLine("<h1>Hello! This is HTML</h1>");
        }
        else
        {
            returnSB.AppendLine($"<h1>Hello, {CurrentBackofficeUser().DisplayName}!</h1>");
        }

        returnSB.AppendLine($"<p>This is rendering on {DateTime.Today.ToShortDateString()}</p>");

        if (!IsBackOfficeAuthorized())
        {
            returnSB.AppendLine($"<p>You are not logged-in</p>");
        }
        else
        {
            returnSB.AppendLine($"<p>You are logged-in as {CurrentBackofficeUserName()}</p>");

            var user = CurrentBackofficeUser();

            returnSB.AppendLine($"<h2>User Object</h2>");
            returnSB.AppendLine($"<textarea id=\"user\" name=\"user\" rows=\"20\" cols=\"500\">{JsonConvert.SerializeObject(user, Formatting.Indented)}</textarea>");


            returnSB.AppendLine($"<h2>Your Claims</h2>");
            var claims = user.AllClaims;
            returnSB.AppendLine($"<ol>");
            foreach (var claim in claims)
            {
                returnSB.AppendLine($"<li>{claim.Type} = {claim.Value}</li>");
            }
            returnSB.AppendLine($"</ol>");
        }

        return Content(returnSB.ToString(), "text/html");
    }
}

SiteSpecificApiConfig.cs

internal class SiteSpecificApiConfig
{
	internal const string ProjectNamespace = "MyApp";
	internal const string ProjectDisplayName = "My Application";
	internal const string ProjectAlias = "MyApp";
	internal const string ApiVersion = "1";
}


internal class SiteSpecificApiBackOfficeRouteAttribute : RouteAttribute
{
	public SiteSpecificApiBackOfficeRouteAttribute(string RoutePath)
		: base($"{SiteSpecificApiConfig.ProjectAlias}/api/v{SiteSpecificApiConfig.ApiVersion}/{RoutePath.TrimStart('/')}")
	{ }
}