Crear una API con scopes y Azure Active Directory

Te habrás dado cuenta de que muchas de las APIs que exponen terceros basan su acceso a la misma en lo que se conoce como scopes. Ya te he hablé de todo ello cuando escribí los artículos sobre OAuth 2 y hoy quiero ayudarte a configurar una API con scopes en Azure AD y a validar los mismos en ASP.NET Core.

Registrar tu API en Azure Active Directory

Lo primero que vas a hacer es registrar tu futura API en Azure Active Directory. Para ello accede al apartado App registrations a través del portal de Microsoft Azure y haz clic en New registration para crear un nuevo registro. En mi ejemplo se llamará WeatherAPI.

Registra tu nueva API en Azure AD – App registrations

Definir los scopes de mi API

Para poder utilizar scopes dentro de tu API necesitas primero definirlos en algún sitio. Ese sitio es el apartado Expose an API de tu aplicación en Azure AD. Antes de agregar ninguno se te pedirá que confirmes el Application ID URI asociado. Por defecto se utiliza el formato api://Client_ID. Yo lo he modificado a api://weather para que sea más sencillo. En cuanto a los scopes, he agregado dos: weather.read, weather.write. El resultado debería de ser como el siguiente:

Scopes para WeatherAPI

Debes tener en cuenta que los scopes están asociados a usuarios, no a aplicaciones.

Registrar una aplicación cliente en Azure Active Directory

Ya tenemos registrada la API pero, para hacerlo bien, necesitamos un cliente que tenga permisos a la misma y a los scopes que has definido. Haz el mismo proceso para una aplicación, en mi caso llamada WeatherClient.

En el apartado API Permissions haz clic sobre Add a permission.

Weather Client – API Permissions – Add a permission

Existen tres pestañas con diferentes APIs de las que puedes registrar sus scopes. En My APIs deberías de ver WeatherAPI, y deberías de poder seleccionar los dos scopes creados en la misma. Selecciónalos para que la foto quede de la siguiente manera:

Recuperar un token desde el cliente con los scopes creados

Para comprobar que todo lo hecho hasta ahora funciona correctamente, necesitas solicitar un token usando WeatherClient con los scopes weather.read y weather.write, con el objetivo de ver que estos son devueltos como parte del token. Existen diferentes maneras de recuperar dicho token, como ya te expliqué. Para este ejemplo voy a utilizar el flujo Authorization Code, por lo que necesito crear un secreto:

Y agregar la URL de redirección en el apartado Authorization:

Ahora, con el código que compartí contigo en el artículo “Ejemplo de Authorization Code Flow de OAuth 2.0“, puedes crear un archivo .env con los siguientes valores:

CLIENT_ID="WEATHERCLIENT_CLIENT_ID"
CLIENT_SECRET="WEATHER_CLIENT_SECRET"
TENANT_ID="TENANT_ID"
SCOPE="api://weather/weather.read api://weather/weather.write"

Necesitarás instalar las dependencias con npm install y ejecutar el sitio con npm start. Al hacer clic en botón Go to the authorization server serás redirigido a tu Azure AD y tendrás que iniciar sesión con un usuario de tu tenant. Si todo está configurado correctamente se le pedirá al mismo el consentimiento para los scopes de WeatherAPI:

Consentimiento para los scopes de Weather API

Una vez aceptado el consentimiento, e intercambiado el código por un token, tendrás un token de acceso que contendrá los scopes necesarios para llamar a la API y que esta los valide. Puedes validar el mismo en http://jwt.ms y comprobarás que tienes un claim llamado scp con tus dos scopes.

El código de la API

Ahora que ya tienes un token con el que llamar, necesitas una API 🙂 en este ejemplo he utilizado la plantilla de ASP.NET Core Web Application – API que devuelve justamente eso: temperaturas.

Antes de realizar ningún cambio necesitas instalar el paquete nuget Microsoft.AspNetCore.Authentication.JwtBearer. Después, en el archivo Startup.cs añade lo siguiente:

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace WebAPI
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllers();


            //1. Add autentication
            services.AddAuthentication(options =>
            {
                options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
                options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
            })
            .AddJwtBearer(options =>
            {
                options.Authority = Configuration["Azure:Authority"];
                options.Audience = Configuration["Azure:Audience"];
            });

            //2. Manage scopes
            services.AddAuthorization(options =>
            {              
                options.AddPolicy("weather.read", policy => policy.Requirements.Add(new ScopeRequirement("weather.read", Configuration["Azure:Authority"])));
                options.AddPolicy("weather.write", policy => policy.Requirements.Add(new ScopeRequirement("weather.write", Configuration["Azure:Authority"])));
            });

            services.AddSingleton<IAuthorizationHandler, ScopeHandler>();

        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseRouting();

            //3. UseAuthentication before UseAuthorization
            app.UseAuthentication();

            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers();
            });
        }
    }
}

El primer paso es añadir la configuración para la autenticación (1. Add authentication) en el método ConfigureServices. En él especifico cuál es mi Authority (issuer), que es quien ha expedido el token, y Audience, que es el la App URI que definimos para la API, http://weather. Estos valores los he almacenado en el archivo appsettings.json:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*",
  "Azure": {
    "Authority": "https://sts.windows.net/YOUR_TENANT_ID/",
    "Audience": "api://weather"
  }
}

Lo segundo que necesitas es verificar los scopes (2. Manage scopes) con los que tu aplicación va a autorizar las peticiones sobre las acciones de tu API. Para ello he creado una clase llamada ScopeRequirement, que hereda de IAuthorizationRequirement, que únicamente almacena el Scope y el Issuer (Authority):

using Microsoft.AspNetCore.Authorization;
using System;

namespace WebAPI
{
    public class ScopeRequirement : IAuthorizationRequirement
    {
        public string Issuer { get; }
        public string Scope { get; }

        public ScopeRequirement(string scope, string issuer)
        {
            Issuer = issuer ?? throw new ArgumentNullException(nameof(issuer));
            Scope = scope ?? throw new ArgumentNullException(nameof(scope));
        }
    }
}

Pero con esto no es suficiente, necesitamos una segunda clase que nos permita saber qué y dónde tiene que buscar, para comprobar que los scopes que nos llegan y los que nos pide una acción determinada como requisito coinciden. Para ello, se utiliza una segunda clase llamada ScopeHandler, que hereda de AuthorizationHandler:

using Microsoft.AspNetCore.Authorization;
using System.Linq;
using System.Threading.Tasks;

namespace WebAPI
{
    public class ScopeHandler : AuthorizationHandler<ScopeRequirement>
    {
        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, ScopeRequirement requirement)
        {
            //Just for checking
            var claims = context.User.Claims.ToList();

            //we need the scope claim. If not do nothing
            if (!context.User.HasClaim(c => c.Type == "http://schemas.microsoft.com/identity/claims/scope" && c.Issuer == requirement.Issuer))
                return Task.CompletedTask;

            //split the scopes (we can have several)
            var scopes = context.User.FindFirst(c => c.Type == "http://schemas.microsoft.com/identity/claims/scope" && c.Issuer == requirement.Issuer).Value.Split(' ');

            //check if we have the right scope for the action
            if (scopes.Any(s => s == requirement.Scope))
                context.Succeed(requirement);

            //if not do nothing
            return Task.CompletedTask;
        }
    }
}

En ella, lo que compruebo es si tengo un claim del tipo que necesito, en este caso scope, además de validar que el issuer es el mismo que especifico. Si tengo este tipo de claim, comparo el que necesita mi acción con el que me ha llegado a través del token y sólo si es válido le permito la ejecución de la llamada.

Para que esta clase sea tomada en cuenta como parte del proceso, debo añadir una instancia de la misma como singleton.

Por último, en el método Configure de mi Startup.cs debo añadir la linea app.UseAuthentication(); antes de app.UseAuthorization();

Decora tus acciones y controllers con scopes

Todo lo que hemos hecho hasta ahora no tendría ningún sentido si luego no utilizas tus scopes 🙂 La forma de hacer uso de ellos es bastante sencilla:

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;

namespace WebAPI.Controllers
{
    [ApiController]    
    [Route("[controller]")]
    public class WeatherForecastController : ControllerBase
    {
         private static readonly string[] Summaries = {
            "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
        };

        private readonly ILogger<WeatherForecastController> _logger;

        public WeatherForecastController(ILogger<WeatherForecastController> logger)
        {
            _logger = logger;
        }

        [HttpGet]
        [Authorize("weather.read")]
        public IEnumerable<WeatherForecast> Get()
        {
            var rng = new Random();
            return Enumerable.Range(1, 5).Select(index => new WeatherForecast
            {
                Date = DateTime.Now.AddDays(index),
                TemperatureC = rng.Next(-20, 55),
                Summary = Summaries[rng.Next(Summaries.Length)]
            })
            .ToArray();
        }

        [HttpPost]
        [Authorize("weather.write")]
        public OkResult Post()
        {
            return Ok();
        }
    }
}

Ahora, si pruebas a lanzar una petición con un token con los scopes necesarios comprobarás que puedes ejecutar tus acciones, previamente validadas para los scopes que se requieran.

El código de la Web API lo tienes en mi GitHub.

¡Saludos!