Skip to content

@umbraco-cms/create-umbraco-mcp-server: composer fix for backoffice-login revoking MCP refresh tokens is tenant-wide, not client-scoped #121

@hifi-phil

Description

@hifi-phil

Symptom

A user has a stable Claude → MCP Worker session. The moment they sign into the Umbraco backoffice with the same user, the MCP connection dies — next tool call returns 401 and the Worker's refresh attempt fails.

Root cause

Umbraco ships RevokeUserAuthenticationTokensNotificationHandler (internal sealed). It listens for UserLoginSuccessNotification and, when SecuritySettings.GetUserAllowConcurrentLogins() is false (the default — StaticAllowConcurrentLogins = false), calls:

await _tokenManager.RevokeUmbracoUserTokens(user.Key);

That extension (OpenIdDictTokenManagerExtensions.cs:16-24) does:

var tokens = await openIddictTokenManager.FindBySubjectAsync(userKey.ToString()).ToArrayAsync();
foreach (var token in tokens) await openIddictTokenManager.DeleteAsync(token);

i.e. every OpenIddict token for that user, across every client_id, gets deleted. That includes the refresh token the hosted MCP Worker (umbraco-cms-editor-mcp-hosted / umbraco-cms-dev-mcp-hosted) is using. Worker copy in KV is fine — it's Umbraco's authoritative OpenIddict store that's been emptied. There is no client-side fix; the IdP has deleted the row.

Current stopgap

Branch fix/concurrent-logins-mcp-token-revocation flips SecuritySettings.UserAllowConcurrentLogins = true from the MCP composers via:

builder.Services.Configure<SecuritySettings>(o => o.UserAllowConcurrentLogins = true);

This short-circuits the handler before it revokes. Lands in:

  • template/umbraco/McpOAuthComposer.cs (self-hosted single-tenant)
  • template/umbraco/McpHostedClientsComposer.Cloud.cs (multi-tenant Cloud, commented-out boilerplate so it ships once uncommented)
  • tests/umbraco-instance/McpOAuthComposer.cs (integration-test fixture)

Why this is a stopgap

The fix relaxes a tenant-wide security policy — concurrent backoffice logins for all users — to keep one specific client's refresh tokens alive. That's a bigger blast radius than the problem requires:

  • A customer with a "one backoffice session per user" policy is now silently overridden by installing an MCP composer.
  • We're not actually granting concurrent backoffice sessions intentionally — we just want MCP tokens to survive.
  • The fix correctly scopes to UserAllowConcurrentLogins (not the broader AllowConcurrentLogins or the MemberAllowConcurrentLogins), so members are untouched, but the principle still applies.

Better options (in increasing complexity)

Option 1 — scope by OpenIddict client (recommended)

Replace Umbraco's handler with a copy that does the same sweep but skips tokens whose Application is one of the registered MCP clients. The global single-session policy stays intact for browser sessions — open a second tab, the first one still gets kicked. MCP refresh tokens survive because they belong to a different client_id.

Plumbing — in the composer's Compose(...):

// Remove Umbraco's internal handler from DI (all three notification interfaces).
var apiMgmtAssembly = typeof(Umbraco.Cms.Api.Management.Security.BackOfficeApplicationManager).Assembly;
var originalType = apiMgmtAssembly.GetType(
    "Umbraco.Cms.Api.Management.Handlers.RevokeUserAuthenticationTokensNotificationHandler",
    throwOnError: true)!;
for (var i = builder.Services.Count - 1; i >= 0; i--)
{
    if (builder.Services[i].ImplementationType == originalType)
    {
        builder.Services.RemoveAt(i);
    }
}

// Register MCP-aware replacement against all three notifications
// the original handles (UserLoginSuccess, UserSaved, UserDeleted)
builder.AddNotificationAsyncHandler<UserLoginSuccessNotification, McpAwareRevokeTokensHandler>();
builder.AddNotificationAsyncHandler<UserSavedNotification,        McpAwareRevokeTokensHandler>();
builder.AddNotificationAsyncHandler<UserDeletedNotification,      McpAwareRevokeTokensHandler>();

The replacement resolves MCP application ids once per call (via IOpenIddictApplicationManager.FindByClientIdAsyncGetIdAsync) and skips tokens whose GetApplicationIdAsync matches:

var preservedAppIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var clientId in McpClientIds) // e.g. ["umbraco-cms-editor-mcp-hosted", ...]
{
    var app = await _applicationManager.FindByClientIdAsync(clientId, ct);
    if (app is null) continue;
    var id = await _applicationManager.GetIdAsync(app, ct);
    if (id is not null) preservedAppIds.Add(id);
}

await foreach (var token in _tokenManager.FindBySubjectAsync(user.Key.ToString()).WithCancellation(ct))
{
    var appId = await _tokenManager.GetApplicationIdAsync(token, ct);
    if (appId is not null && preservedAppIds.Contains(appId)) continue;
    await _tokenManager.DeleteAsync(token, ct);
}

Caveats:

  • Uses reflection against an internal type. Brittle if the handler is renamed upstream — but throwOnError: true makes the failure loud at startup.
  • Must cover all three INotificationAsyncHandler<> interfaces the original implements (UserLoginSuccessNotification, UserSavedNotification, UserDeletedNotification), or we silently regress the lockout/delete revocation paths.

Option 2 — scope by user

Allowlist a dedicated MCP service user (by email / key / group) and skip the revocation entirely for that user. Lighter touch but assumes operators run a separate service account for MCP — usually they don't. Also restores full concurrent backoffice rights for the allowlisted user (a leftover browser session won't get kicked), which is broader than we need.

Option 3 — combine: user AND client

Maximally defensive. Run the original sweep; skip only when token's client is MCP AND user is in the MCP allowlist. Probably overkill.

Recommendation

Ship Option 1 in @umbraco-cms/create-umbraco-mcp-server template (replace the current Configure<SecuritySettings> line), and revert the global config relax. ~60 lines of replacement handler + DI removal, against an internal type. Worth the reflection cost to avoid changing tenant-wide login policy as a side-effect of installing an MCP composer.

Upstream fix

The real bug is in Umbraco CMS: RevokeUmbracoUserTokens(user.Key) should not be a blanket subject-wide sweep. The handler should at minimum filter to tokens whose client is the backoffice cookie/SPA client (Constants.OAuthClientIds.BackOffice), leaving every other registered OpenIddict client's tokens alone. Concurrent-login enforcement was clearly designed for "kick the other browser tab," not "kill every machine-to-machine session." Worth a separate issue on umbraco/Umbraco-CMS. Once that lands, this template-side workaround goes away entirely.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions