fullstackhero/dotnet-starter-kit · error · UnauthorizedException

missing tenant context

Error message

missing tenant context

What it means

RevokeImpersonationGrantCommandHandler.Handle throws UnauthorizedException('missing tenant context') when the authenticated caller's claims contain no tenant identifier. Impersonation grants are tenant-scoped, so revocation cannot enforce visibility rules without knowing the caller's tenant. The exception maps to HTTP 401.

Solutions

  1. Issue/refresh the token so it includes the tenant claim, then retry
  2. Ensure the request reaches the API through the tenant resolution mechanism (header/route/host as configured) so claims include the tenant
  3. If calling programmatically, set the tenant identifier header expected by the multitenancy config

Example fix

// before
classicHttpClient.DefaultRequestHeaders.Remove("tenant");
// after
client.DefaultRequestHeaders.Add("tenant", tenantId); // ensure tenant is resolved for the JWT/claims
Defensive patterns

Strategy: validation

Validate before calling

const tenant = claims.find(c => c.type === 'tenant')?.value;
if (!tenant) throw new Error('token has no tenant claim; re-authenticate');

Type guard

function hasTenantClaim(c: {type: string; value?: string}[]): c is {type: string; value: string}[] {
  return !!c.find(x => x.type === 'tenant' && !!x.value);
}

Try / catch

try { await api.revokeImpersonationGrant(grantId); }
catch (e) { if (e.status === 401 && e.message.includes('tenant context')) { await reauthenticateWithTenant(tenantId); } else throw e; }

Prevention

When it happens

Trigger: Calling POST/DELETE for revoking an impersonation grant with a valid JWT that lacks the tenant claim (e.g. a token issued outside Finbuckle multitenancy resolution, or a hand-crafted/service token without tenant info).

Common situations: Tokens minted by custom auth flows that omit the __tenant__ claim; calling the endpoint from a background service or CLI that reuses a token without tenant headers; misconfigured Finbuckle tenant resolver so the tenant is not resolved into the user's claims.

Related errors


AI-assisted analysis of fullstackhero/dotnet-starter-kit@3f2959e683 (2026-09-15). Data as JSON: /api/errors/dc331ca38cffe6b8. Report an issue: GitHub.

Appendix: source

Thrown at src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/RevokeImpersonationGrant/RevokeImpersonationGrantCommandHandler.cs:34

    ISecurityAudit securityAudit,
    IRequestContext requestContext,
    ILogger<RevokeImpersonationGrantCommandHandler> logger)
    : ICommandHandler<RevokeImpersonationGrantCommand, ImpersonationGrantDto>
{
    public async ValueTask<ImpersonationGrantDto> Handle(
        RevokeImpersonationGrantCommand request,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(request);

        if (!currentUser.IsAuthenticated())
        {
            throw new UnauthorizedException();
        }

        var callerUserId = currentUser.GetUserId().ToString();
        var callerTenantId = currentUser.GetTenant()
            ?? throw new UnauthorizedException("missing tenant context");
        var isRoot = string.Equals(callerTenantId, MultitenancyConstants.Root.Id, StringComparison.Ordinal);

        // Enforce visibility before revoking: tenant admins may only revoke grants in their own
        // tenant. Cross-tenant grants return 404 (not 403) so existence isn't confirmed out of scope.
        var grant = await grantService.GetByIdAsync(request.GrantId, cancellationToken).ConfigureAwait(false)
            ?? throw new NotFoundException("impersonation grant not found");

        var withinTenant = string.Equals(grant.ImpersonatedTenantId, callerTenantId, StringComparison.Ordinal)
            || string.Equals(grant.ActorTenantId, callerTenantId, StringComparison.Ordinal);

        if (!isRoot && !withinTenant)
        {
            throw new NotFoundException("impersonation grant not found");
        }

        var updated = await grantService.RevokeAsync(
            id: request.GrantId,
            revokedByUserId: callerUserId,

View on GitHub (pinned to 3f2959e683)